commit 2e2e64c08bcef3b84952699ee9f685fc39cc7fa1 Author: Ilia Date: Thu Jul 9 12:22:15 2026 -0400 Initial commit: Hearth consent-only email warmup tool diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..39dae90 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +.venv/ +venv/ +.env +hearth.yml +data/ +*.db +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9917c40 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY hearth ./hearth +ENV PYTHONUNBUFFERED=1 +VOLUME ["/app/data"] +HEALTHCHECK --interval=5m --timeout=10s --start-period=30s \ + CMD python -m hearth.main --healthcheck || exit 1 +CMD ["python", "-m", "hearth.main", "--loop"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..eb696b2 --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# Hearth + +A small, **consent-only** email warmup service for `levkine.ca`. It exchanges +real-looking (but genuinely sent/replied) mail between mailboxes you own and +a fixed allow-list of people who've explicitly agreed to help, gradually +building the engagement signals (opens, replies, "not spam" marks) that +improve sender reputation with Gmail/Outlook/etc. + +## 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_` env var, sourced from your secrets + manager — never hardcoded), sends new threads, auto-replies to incoming + pool mail, and un-spams anything a provider misfiled. + - `send_only` — a consenting friend/coworker. Hearth only ever sends + **to** their address from one of your `full_duplex` mailboxes. It never + holds credentials for them and never logs into their account — they + reply or mark-not-spam manually, on their own, whenever they want. +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. +4. **Kill switch + dry run** — `HEARTH_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 + +```bash +cp hearth.yml.example hearth.yml # edit: your mailboxes + consenting participants +cp env.example .env # edit: HEARTH_PASSWORD_ per full_duplex mailbox +docker compose up -d --build +docker compose logs -f +``` + +Dry run locally without Docker: + +```bash +python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt +HEARTH_CONFIG=./hearth.yml HEARTH_DB_PATH=./data/hearth.db \ + HEARTH_PASSWORD_ALERTS=... .venv/bin/python -m hearth.main --once --dry-run +``` + +Run the test suite (includes the allow-list guardrail tests): + +```bash +.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`):** confirm with them first, then 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. + +## 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, 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. diff --git a/deploy-hearth.sh b/deploy-hearth.sh new file mode 100755 index 0000000..9376daf --- /dev/null +++ b/deploy-hearth.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Deploy Hearth to a target host via rsync + docker compose. +# No default host on purpose — pick/provision an LXC first, see +# docs/guides/hearth-deploy.md in the ansible repo, then: +# +# HEARTH_HOST=10.0.10.xx ./deploy-hearth.sh +# +# Assumes: hearth.yml and .env already exist locally (copied from the +# .example files and filled in) — they are rsync'd but never committed. +set -euo pipefail + +: "${HEARTH_HOST:?Set HEARTH_HOST= (see docs/guides/hearth-deploy.md)}" +REMOTE_DIR="${HEARTH_REMOTE_DIR:-/opt/hearth}" +REPO="$(cd "$(dirname "$0")" && pwd)" + +for required in hearth.yml .env; do + if [[ ! -f "${REPO}/${required}" ]]; then + echo "Missing ${REPO}/${required} — copy ${required}.example and fill it in first." >&2 + exit 1 + fi +done + +echo "=== Hearth -> ${HEARTH_HOST}:${REMOTE_DIR} ===" +ssh -o StrictHostKeyChecking=accept-new "root@${HEARTH_HOST}" "mkdir -p ${REMOTE_DIR}/data" + +rsync -az --delete \ + --exclude='.git' --exclude='.venv' --exclude='__pycache__' \ + --exclude='data/' --exclude='.pytest_cache' \ + "${REPO}/" "root@${HEARTH_HOST}:${REMOTE_DIR}/" + +ssh "root@${HEARTH_HOST}" "cd ${REMOTE_DIR} && docker compose up -d --build" + +echo "=== Status ===" +ssh "root@${HEARTH_HOST}" "cd ${REMOTE_DIR} && docker compose ps && docker compose logs --tail=20 hearth" +echo "" +echo "Kill switch: ssh root@${HEARTH_HOST} \"sed -i 's/^HEARTH_ENABLED=.*/HEARTH_ENABLED=false/' ${REMOTE_DIR}/.env && cd ${REMOTE_DIR} && docker compose up -d\"" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..dfb63ff --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +# Hearth — consent-only email warmup service +name: hearth +services: + hearth: + build: . + container_name: hearth + env_file: + - path: .env + required: false + volumes: + - ./hearth.yml:/app/hearth.yml:ro + - ./data:/app/data + restart: unless-stopped + # No published ports — Hearth only ever talks outbound (SMTP/IMAP) to the + # participants in hearth.yml. It has no HTTP surface to expose. diff --git a/env.example b/env.example new file mode 100644 index 0000000..d817168 --- /dev/null +++ b/env.example @@ -0,0 +1,44 @@ +# Hearth environment — copy to .env, fill in real values, never commit .env +# +# Global kill switch. Set to false to stop ALL sending/replying immediately +# without touching the config or code. +HEARTH_ENABLED=true + +# Path to the participant allow-list (mounted read-only in docker-compose.yml) +HEARTH_CONFIG=/app/hearth.yml +HEARTH_DB_PATH=/app/data/hearth.db + +# Scheduling +HEARTH_TIMEZONE=America/Toronto +HEARTH_POLL_INTERVAL_SECONDS=300 +HEARTH_BUSINESS_HOURS_START=8 +HEARTH_BUSINESS_HOURS_END=20 +HEARTH_ACTIVE_WEEKDAYS_ONLY=true + +# Warmup ramp curve: daily send cap starts at HEARTH_DAILY_CAP_START on day 1 +# and increases by HEARTH_RAMP_STEP_PER_DAY each day up to HEARTH_DAILY_CAP_MAX. +HEARTH_DAILY_CAP_START=2 +HEARTH_DAILY_CAP_MAX=20 +HEARTH_RAMP_STEP_PER_DAY=1 + +# How long to wait before auto-replying to an incoming pool message, to look +# like a human reading their email rather than a bot (minutes). +HEARTH_MIN_REPLY_DELAY_MIN=5 +HEARTH_MAX_REPLY_DELAY_MIN=180 + +# Optional: use your existing Ollama / Open WebUI instance to vary message +# text instead of the static template bank. Leave blank to use templates only. +HEARTH_LLM_URL= +HEARTH_LLM_MODEL=llama3.1 + +# Optional: daily summary via your existing ntfy instance +NTFY_URL=https://push.levkin.ca +NTFY_PUBLISH_TOKEN= +NTFY_TOPIC=hearth-digest + +# --- Per-participant mailbox credentials ----------------------------------- +# One HEARTH_PASSWORD_ per full_duplex participant in hearth.yml +# (KEY uppercased, matching the participant's `key:`). send_only participants +# need no password — Hearth never authenticates as them. +HEARTH_PASSWORD_ALERTS= +HEARTH_PASSWORD_PERSONAL_GMAIL= diff --git a/hearth.yml.example b/hearth.yml.example new file mode 100644 index 0000000..c42473d --- /dev/null +++ b/hearth.yml.example @@ -0,0 +1,48 @@ +# Hearth participant allow-list — copy to hearth.yml and fill in real values. +# NEVER commit hearth.yml with real addresses/creds (it's gitignored). +# +# This file is the ONLY place recipients come from. Hearth has no code path +# that discovers, scrapes, or composes an address that isn't listed here. +# +# tier: full_duplex +# Your own mailboxes. Hearth logs in via IMAP+SMTP, sends new threads, +# and auto-replies to incoming pool mail. Needs imap_host/smtp_host/ +# username + a HEARTH_PASSWORD_ env var (see env.example). +# +# tier: send_only +# A friend/coworker who explicitly agreed to help warm up levkine.ca. +# Hearth only ever sends TO this address from one of your full_duplex +# mailboxes — it never has credentials for this inbox, and never logs +# into it. The participant replies/marks-not-spam manually, whenever +# they feel like it, from their own mail client. + +participants: + alerts: + tier: full_duplex + email: alerts@levkine.ca + display_name: "Ilia" + imap_host: mail.levkine.ca + imap_port: 993 + smtp_host: mail.levkine.ca + smtp_port: 587 + username: alerts@levkine.ca + password_env: HEARTH_PASSWORD_ALERTS + + personal_gmail: + tier: full_duplex + email: idobkin@gmail.com + display_name: "Ilia" + imap_host: imap.gmail.com + imap_port: 993 + smtp_host: smtp.gmail.com + smtp_port: 587 + username: idobkin@gmail.com + password_env: HEARTH_PASSWORD_PERSONAL_GMAIL + + # Example consenting third party — replace with a real person who has + # actually agreed to this. Delete this entry if you only want to warm up + # between your own mailboxes for now. + friend_example: + tier: send_only + email: friend@example.com + display_name: "Friend" diff --git a/hearth/__init__.py b/hearth/__init__.py new file mode 100644 index 0000000..381a4bf --- /dev/null +++ b/hearth/__init__.py @@ -0,0 +1,3 @@ +"""Hearth — consent-only email warmup for levkine.ca.""" + +__version__ = "0.1.0" diff --git a/hearth/composer.py b/hearth/composer.py new file mode 100644 index 0000000..d02dd07 --- /dev/null +++ b/hearth/composer.py @@ -0,0 +1,80 @@ +"""Message content generation. + +Default is a small hand-written template bank (short, plain-text, varied — +deliberately the opposite of bulk/marketing copy). If HEARTH_LLM_URL is set +(pointing at your existing Ollama / Open WebUI instance), new-thread openers +and replies are generated there instead for more variety, with the template +bank as a fallback on any error/timeout. +""" +from __future__ import annotations + +import random + +import httpx + +from hearth.settings import settings + +OPENERS = [ + ("Quick check-in", "Hey {name}, just checking in — how's your week going?"), + ("Question for you", "Hey {name}, quick one for you when you get a sec — how's everything on your end?"), + ("Following up", "Hi {name}, following up from last time — anything new on your side?"), + ("Random thought", "Hey {name}, random thought but figured I'd send it your way. Hope you're doing well!"), + ("Catching up", "Hi {name} — been a while! How have things been?"), + ("Quick note", "Hey {name}, quick note to say hi and see how you're doing."), +] + +REPLIES = [ + "Thanks for the note — all good here, just been busy. How about you?", + "Good timing, I was just thinking about that. Things are going well on my end!", + "Appreciate you checking in! Been keeping busy but can't complain.", + "All good here, thanks! Let's catch up properly soon.", + "Ha, funny you mention that. Things have been steady, how about yours?", + "Doing well, thanks for asking! Hope things are good with you too.", +] + + +def _first_name(display_name: str, email: str) -> str: + if display_name: + return display_name.split()[0] + return email.split("@")[0].replace(".", " ").replace("_", " ").title() + + +def _try_llm(prompt: str) -> str | None: + if not settings.llm_url: + return None + try: + resp = httpx.post( + f"{settings.llm_url.rstrip('/')}/api/generate", + json={"model": settings.llm_model, "prompt": prompt, "stream": False}, + timeout=15.0, + ) + resp.raise_for_status() + text = (resp.json() or {}).get("response", "").strip() + return text or None + except Exception: + return None + + +def compose_new_thread(recipient_name: str, recipient_email: str) -> tuple[str, str]: + name = _first_name(recipient_name, recipient_email) + subject, template = random.choice(OPENERS) + + llm_body = _try_llm( + "Write one short, casual, friendly email (2-3 sentences, plain text, " + f"no subject line) checking in with a friend named {name}. Keep it natural " + "and low-key, like a real person catching up, not a marketing email." + ) + body = llm_body or template.format(name=name) + return subject, body + + +def compose_reply(original_subject: str, recipient_name: str, recipient_email: str) -> tuple[str, str]: + name = _first_name(recipient_name, recipient_email) + subject = original_subject if original_subject.lower().startswith("re:") else f"Re: {original_subject}" + + llm_body = _try_llm( + f"Write one short, casual, friendly reply (2-3 sentences, plain text) to {name}, " + "responding to a check-in email. Keep it natural, like a real person, not a bot." + ) + body = llm_body or random.choice(REPLIES) + return subject, body diff --git a/hearth/config.py b/hearth/config.py new file mode 100644 index 0000000..0852669 --- /dev/null +++ b/hearth/config.py @@ -0,0 +1,140 @@ +"""Participant allow-list loader. + +This module is the single source of truth for "who Hearth is allowed to +email". Every other module must go through `Config.participants` / +`Config.get(key)` to resolve an address — there is no function anywhere in +this codebase that accepts a freeform email string and sends to it. +""" +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path + +import yaml + +VALID_TIERS = {"full_duplex", "send_only"} + + +class ConfigError(ValueError): + pass + + +@dataclass(frozen=True) +class Participant: + key: str + tier: str + email: str + display_name: str = "" + imap_host: str | None = None + imap_port: int = 993 + smtp_host: str | None = None + smtp_port: int = 587 + username: str | None = None + password_env: str | None = None + + @property + def can_authenticate(self) -> bool: + """Only full_duplex participants ever get IMAP/SMTP credentials.""" + return self.tier == "full_duplex" + + @property + def password(self) -> str: + if not self.can_authenticate: + raise ConfigError( + f"participant '{self.key}' is send_only — Hearth never holds " + "credentials for it and must not authenticate as it" + ) + if not self.password_env: + raise ConfigError(f"participant '{self.key}' has no password_env set") + value = os.environ.get(self.password_env, "") + if not value: + raise ConfigError( + f"participant '{self.key}' password_env '{self.password_env}' is unset" + ) + return value + + +@dataclass(frozen=True) +class Config: + participants: dict[str, Participant] = field(default_factory=dict) + + def get(self, key: str) -> Participant: + try: + return self.participants[key] + except KeyError as exc: + raise ConfigError(f"unknown participant key '{key}' (not in allow-list)") from exc + + def is_known_email(self, email: str) -> str | None: + """Return the participant key for an email address, or None if it's + not on the allow-list. Case-insensitive exact match only — no + pattern/domain matching, so a lookalike address never matches.""" + needle = email.strip().lower() + for key, participant in self.participants.items(): + if participant.email.strip().lower() == needle: + return key + return None + + def full_duplex_keys(self) -> list[str]: + return [k for k, p in self.participants.items() if p.tier == "full_duplex"] + + def all_keys(self) -> list[str]: + return list(self.participants.keys()) + + +def load_config(path: str | Path) -> Config: + path = Path(path) + if not path.is_file(): + raise ConfigError( + f"config file not found: {path} (copy hearth.yml.example to hearth.yml)" + ) + + raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + raw_participants = raw.get("participants") or {} + if not isinstance(raw_participants, dict) or not raw_participants: + raise ConfigError("hearth.yml must define at least one participant under 'participants:'") + + participants: dict[str, Participant] = {} + seen_emails: dict[str, str] = {} + + for key, meta in raw_participants.items(): + key = str(key).strip() + if not isinstance(meta, dict): + raise ConfigError(f"participant '{key}' must be a mapping") + + tier = str(meta.get("tier", "")).strip() + if tier not in VALID_TIERS: + raise ConfigError(f"participant '{key}' has invalid tier '{tier}' (expected {VALID_TIERS})") + + email = str(meta.get("email", "")).strip().lower() + if not email or "@" not in email: + raise ConfigError(f"participant '{key}' has a missing/invalid email") + + if email in seen_emails: + raise ConfigError( + f"duplicate email '{email}' used by both '{seen_emails[email]}' and '{key}'" + ) + seen_emails[email] = key + + if tier == "full_duplex": + for required in ("imap_host", "smtp_host", "username", "password_env"): + if not meta.get(required): + raise ConfigError(f"full_duplex participant '{key}' missing '{required}'") + + participants[key] = Participant( + key=key, + tier=tier, + email=email, + display_name=str(meta.get("display_name", "")).strip(), + imap_host=meta.get("imap_host"), + imap_port=int(meta.get("imap_port", 993)), + smtp_host=meta.get("smtp_host"), + smtp_port=int(meta.get("smtp_port", 587)), + username=meta.get("username"), + password_env=meta.get("password_env"), + ) + + if not any(p.tier == "full_duplex" for p in participants.values()): + raise ConfigError("hearth.yml needs at least one full_duplex participant to send from") + + return Config(participants=participants) diff --git a/hearth/mailer.py b/hearth/mailer.py new file mode 100644 index 0000000..97256a4 --- /dev/null +++ b/hearth/mailer.py @@ -0,0 +1,97 @@ +"""SMTP sending — the only place that talks to an SMTP server. + +Defense in depth: `send` re-validates the recipient against the allow-list +itself (in addition to callers already having done so), so a bug elsewhere +in the codebase can never result in mail going to an address that isn't in +hearth.yml. +""" +from __future__ import annotations + +import logging +import smtplib +import uuid +from dataclasses import dataclass +from email.message import EmailMessage +from email.utils import make_msgid + +from hearth.config import Config, ConfigError, Participant +from hearth.settings import settings + +log = logging.getLogger("hearth.mailer") + + +class SendBlocked(RuntimeError): + pass + + +@dataclass +class SendResult: + message_id: str + dry_run: bool + + +def _connect(sender: Participant) -> smtplib.SMTP: + if sender.smtp_port == 465: + smtp = smtplib.SMTP_SSL(sender.smtp_host, sender.smtp_port, timeout=20) + else: + smtp = smtplib.SMTP(sender.smtp_host, sender.smtp_port, timeout=20) + smtp.starttls() + smtp.login(sender.username, sender.password) + return smtp + + +def send( + config: Config, + *, + sender_key: str, + recipient_key: str, + subject: str, + body: str, + in_reply_to: str | None = None, + references: str | None = None, +) -> SendResult: + sender = config.get(sender_key) + recipient = config.get(recipient_key) + + if not sender.can_authenticate: + raise SendBlocked(f"sender '{sender_key}' is send_only — Hearth cannot send as it") + + # Belt-and-suspenders: confirm the recipient's email is still on the + # allow-list right now, by exact match, not just by key lookup above. + if config.is_known_email(recipient.email) != recipient_key: + raise SendBlocked(f"recipient email for '{recipient_key}' does not match allow-list") + + message_id = make_msgid(domain=sender.email.split("@", 1)[-1]) + + if settings.dry_run or not settings.enabled: + log.info( + "[dry-run] would send %s -> %s subject=%r message_id=%s", + sender.email, + recipient.email, + subject, + message_id, + ) + return SendResult(message_id=message_id, dry_run=True) + + msg = EmailMessage() + msg["From"] = f"{sender.display_name} <{sender.email}>" if sender.display_name else sender.email + msg["To"] = recipient.email + msg["Subject"] = subject + msg["Message-ID"] = message_id + if in_reply_to: + msg["In-Reply-To"] = in_reply_to + if references: + msg["References"] = references + msg.set_content(body) + + smtp = _connect(sender) + try: + smtp.send_message(msg) + finally: + try: + smtp.quit() + except Exception: + pass + + log.info("sent %s -> %s subject=%r message_id=%s", sender.email, recipient.email, subject, message_id) + return SendResult(message_id=message_id, dry_run=False) diff --git a/hearth/main.py b/hearth/main.py new file mode 100644 index 0000000..8cf8d46 --- /dev/null +++ b/hearth/main.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import argparse +import logging +import sys +import time +from pathlib import Path + +from hearth import scheduler, watcher +from hearth.config import ConfigError, load_config +from hearth.settings import settings +from hearth.store import Store + +log = logging.getLogger("hearth.main") +HEALTHCHECK_MAX_AGE_SECONDS = 15 * 60 + + +def _heartbeat_path() -> Path: + return Path(settings.db_path).with_name(".heartbeat") + + +def _ntfy_daily_summary(store: Store) -> None: + if not settings.ntfy_publish_token: + return + summary = store.daily_summary() + try: + import httpx + + httpx.post( + f"{settings.ntfy_url.rstrip('/')}/{settings.ntfy_topic}", + headers={ + "Authorization": f"Bearer {settings.ntfy_publish_token}", + "Title": "Hearth daily summary", + "Priority": "3", + }, + content=( + f"sent={summary['sent']} replied={summary['replied']} " + f"unspammed={summary['unspammed']} bounced={summary['bounced']}" + ), + timeout=10, + ) + except Exception: + log.exception("ntfy summary failed") + + +def run_once(store: Store, config) -> None: + watcher.poll_all(config, store) + replied = watcher.send_due_replies(config, store) + started = scheduler.maybe_start_new_thread(config, store) + if replied or started: + log.info("cycle done: replies_sent=%d new_thread=%s", replied, started) + heartbeat = _heartbeat_path() + heartbeat.parent.mkdir(parents=True, exist_ok=True) + heartbeat.write_text(str(time.time())) + + +def run_loop(store: Store, config) -> None: + last_summary_day = None + while True: + try: + run_once(store, config) + today = __import__("datetime").date.today() + if today != last_summary_day: + _ntfy_daily_summary(store) + last_summary_day = today + except Exception: + log.exception("cycle failed, will retry next interval") + time.sleep(settings.poll_interval_seconds) + + +def _healthcheck() -> int: + heartbeat = _heartbeat_path() + if not heartbeat.is_file(): + return 1 + age = time.time() - heartbeat.stat().st_mtime + return 0 if age < HEALTHCHECK_MAX_AGE_SECONDS else 1 + + +def main(argv: list[str] | None = None) -> int: + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s") + + parser = argparse.ArgumentParser(description="Hearth — consent-only email warmup") + parser.add_argument("--loop", action="store_true", help="run forever, polling every HEARTH_POLL_INTERVAL_SECONDS") + parser.add_argument("--once", action="store_true", help="run a single cycle and exit") + parser.add_argument("--dry-run", action="store_true", help="log intended sends without actually sending") + parser.add_argument("--healthcheck", action="store_true", help="exit 0 if the loop is healthy, else 1") + args = parser.parse_args(argv) + + if args.healthcheck: + return _healthcheck() + + if args.dry_run: + settings.dry_run = True + log.info("dry-run mode enabled via --dry-run") + + try: + config = load_config(settings.config_path) + except ConfigError as exc: + log.error("config error: %s", exc) + return 1 + + store = Store(db_path=settings.db_path) + + log.info( + "Hearth starting: %d participant(s) (%d full_duplex), enabled=%s dry_run=%s", + len(config.all_keys()), + len(config.full_duplex_keys()), + settings.enabled, + settings.dry_run, + ) + + if args.loop: + run_loop(store, config) + else: + run_once(store, config) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/hearth/scheduler.py b/hearth/scheduler.py new file mode 100644 index 0000000..28fe820 --- /dev/null +++ b/hearth/scheduler.py @@ -0,0 +1,119 @@ +"""Decides *whether* to start a new warmup thread this cycle, respecting the +ramp curve, daily cap, and business-hours window. Reply timing/sending is +handled separately in watcher.send_due_replies (already delay-randomized). +""" +from __future__ import annotations + +import logging +import random +from datetime import datetime +from zoneinfo import ZoneInfo + +from hearth import mailer +from hearth.composer import compose_new_thread +from hearth.config import Config +from hearth.settings import settings +from hearth.store import Store + +log = logging.getLogger("hearth.scheduler") + + +def daily_cap(store: Store) -> int: + day = store.ramp_day() + cap = settings.daily_cap_start + settings.ramp_step_per_day * day + return max(1, int(round(min(cap, settings.daily_cap_max)))) + + +def _now() -> datetime: + try: + return datetime.now(ZoneInfo(settings.timezone)) + except Exception: + return datetime.now() + + +def in_business_hours(now: datetime) -> bool: + if settings.active_weekdays_only and now.weekday() >= 5: + return False + return settings.business_hours_start <= now.hour < settings.business_hours_end + + +def _minutes_left_in_business_day(now: datetime) -> float: + end = now.replace(hour=settings.business_hours_end, minute=0, second=0, microsecond=0) + if now >= end: + return 0.0 + return (end - now).total_seconds() / 60.0 + + +def maybe_start_new_thread(config: Config, store: Store) -> bool: + if not settings.enabled: + log.debug("HEARTH_ENABLED=false — skipping send cycle") + return False + + now = _now() + if not in_business_hours(now): + return False + + cap = daily_cap(store) + sent_today = store.sent_today_total() + remaining = cap - sent_today + if remaining <= 0: + return False + + minutes_left = _minutes_left_in_business_day(now) + poll_minutes = max(settings.poll_interval_seconds / 60.0, 1.0) + if minutes_left <= 0: + return False + + # Spread the remaining quota roughly evenly across the remaining polls + # in today's business window, instead of bursting. + probability = min(1.0, (poll_minutes / minutes_left) * remaining) + if random.random() > probability: + return False + + eligible_senders = [k for k in config.full_duplex_keys() if store.sent_today(k) < cap] + if not eligible_senders: + return False + sender_key = random.choice(eligible_senders) + + candidates = [k for k in config.all_keys() if k != sender_key] + if not candidates: + return False + recipient_key = random.choice(candidates) + + recipient = config.get(recipient_key) + subject, body = compose_new_thread(recipient.display_name, recipient.email) + + try: + result = mailer.send( + config, + sender_key=sender_key, + recipient_key=recipient_key, + subject=subject, + body=body, + ) + except Exception: + log.exception("failed to send new thread %s -> %s", sender_key, recipient_key) + return False + + thread_id = store.thread_id_for(None) + store.record_message( + thread_id=thread_id, + from_key=sender_key, + to_key=recipient_key, + direction="sent", + message_id_header=result.message_id, + in_reply_to=None, + subject=subject, + ) + if not result.dry_run: + store.increment_sent_today(sender_key) + + log.info( + "new thread day=%d cap=%d sent_today=%d -> %s to %s", + store.ramp_day(), + cap, + sent_today + 1, + sender_key, + recipient_key, + ) + return True diff --git a/hearth/settings.py b/hearth/settings.py new file mode 100644 index 0000000..f984ed1 --- /dev/null +++ b/hearth/settings.py @@ -0,0 +1,43 @@ +"""Global runtime settings, sourced from environment variables only. + +Nothing in here can name a recipient — that only ever comes from the +participant allow-list loaded in config.py. +""" +from __future__ import annotations + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(extra="ignore") + + enabled: bool = Field(default=True, validation_alias="HEARTH_ENABLED") + config_path: str = Field(default="./hearth.yml", validation_alias="HEARTH_CONFIG") + db_path: str = Field(default="./data/hearth.db", validation_alias="HEARTH_DB_PATH") + + timezone: str = Field(default="America/Toronto", validation_alias="HEARTH_TIMEZONE") + poll_interval_seconds: int = Field(default=300, validation_alias="HEARTH_POLL_INTERVAL_SECONDS") + business_hours_start: int = Field(default=8, validation_alias="HEARTH_BUSINESS_HOURS_START") + business_hours_end: int = Field(default=20, validation_alias="HEARTH_BUSINESS_HOURS_END") + active_weekdays_only: bool = Field(default=True, validation_alias="HEARTH_ACTIVE_WEEKDAYS_ONLY") + + daily_cap_start: int = Field(default=2, validation_alias="HEARTH_DAILY_CAP_START") + daily_cap_max: int = Field(default=20, validation_alias="HEARTH_DAILY_CAP_MAX") + ramp_step_per_day: float = Field(default=1.0, validation_alias="HEARTH_RAMP_STEP_PER_DAY") + + min_reply_delay_min: int = Field(default=5, validation_alias="HEARTH_MIN_REPLY_DELAY_MIN") + max_reply_delay_min: int = Field(default=180, validation_alias="HEARTH_MAX_REPLY_DELAY_MIN") + + llm_url: str = Field(default="", validation_alias="HEARTH_LLM_URL") + llm_model: str = Field(default="llama3.1", validation_alias="HEARTH_LLM_MODEL") + + ntfy_url: str = Field(default="https://push.levkin.ca", validation_alias="NTFY_URL") + ntfy_token: str = Field(default="", validation_alias="NTFY_TOPIC_TOKEN") + ntfy_publish_token: str = Field(default="", validation_alias="NTFY_PUBLISH_TOKEN") + ntfy_topic: str = Field(default="hearth-digest", validation_alias="NTFY_TOPIC") + + dry_run: bool = Field(default=False, validation_alias="HEARTH_DRY_RUN") + + +settings = Settings() diff --git a/hearth/store.py b/hearth/store.py new file mode 100644 index 0000000..6f45ed4 --- /dev/null +++ b/hearth/store.py @@ -0,0 +1,265 @@ +"""SQLite-backed state: daily send counters, ramp start date, thread/message +history, and observed bounce/spam-folder events. No participant data is +duplicated here beyond the `key` values already validated by config.py.""" +from __future__ import annotations + +import sqlite3 +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import date, datetime, timezone +from pathlib import Path + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS daily_counts ( + day TEXT NOT NULL, + sender_key TEXT NOT NULL, + sent_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (day, sender_key) +); + +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + thread_id TEXT NOT NULL, + from_key TEXT NOT NULL, + to_key TEXT NOT NULL, + direction TEXT NOT NULL CHECK (direction IN ('sent', 'received')), + message_id_header TEXT, + in_reply_to TEXT, + subject TEXT, + status TEXT NOT NULL DEFAULT 'sent', + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id); +CREATE INDEX IF NOT EXISTS idx_messages_message_id ON messages(message_id_header); + +CREATE TABLE IF NOT EXISTS reply_queue ( + message_id_header TEXT PRIMARY KEY, + thread_id TEXT NOT NULL, + mailbox_key TEXT NOT NULL, + sender_key TEXT NOT NULL, + subject TEXT NOT NULL, + references_header TEXT, + scheduled_at TEXT NOT NULL, + created_at TEXT NOT NULL +); +""" + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +@dataclass +class Store: + db_path: str + + def __post_init__(self) -> None: + Path(self.db_path).parent.mkdir(parents=True, exist_ok=True) + with self._conn() as conn: + conn.executescript(SCHEMA) + + @contextmanager + def _conn(self): + conn = sqlite3.connect(self.db_path) + try: + yield conn + conn.commit() + finally: + conn.close() + + # -- ramp ----------------------------------------------------------- + def get_or_set_start_date(self) -> date: + with self._conn() as conn: + row = conn.execute("SELECT value FROM state WHERE key = 'start_date'").fetchone() + if row: + return date.fromisoformat(row[0]) + today = date.today() + conn.execute( + "INSERT INTO state (key, value) VALUES ('start_date', ?)", (today.isoformat(),) + ) + return today + + def ramp_day(self) -> int: + start = self.get_or_set_start_date() + return max(0, (date.today() - start).days) + + # -- daily caps ------------------------------------------------------- + def sent_today(self, sender_key: str) -> int: + today = date.today().isoformat() + with self._conn() as conn: + row = conn.execute( + "SELECT sent_count FROM daily_counts WHERE day = ? AND sender_key = ?", + (today, sender_key), + ).fetchone() + return row[0] if row else 0 + + def sent_today_total(self) -> int: + today = date.today().isoformat() + with self._conn() as conn: + row = conn.execute( + "SELECT COALESCE(SUM(sent_count), 0) FROM daily_counts WHERE day = ?", + (today,), + ).fetchone() + return row[0] if row else 0 + + def increment_sent_today(self, sender_key: str) -> None: + today = date.today().isoformat() + with self._conn() as conn: + conn.execute( + """ + INSERT INTO daily_counts (day, sender_key, sent_count) + VALUES (?, ?, 1) + ON CONFLICT(day, sender_key) DO UPDATE SET sent_count = sent_count + 1 + """, + (today, sender_key), + ) + + # -- messages --------------------------------------------------------- + def record_message( + self, + *, + thread_id: str, + from_key: str, + to_key: str, + direction: str, + message_id_header: str | None, + in_reply_to: str | None, + subject: str, + status: str = "sent", + ) -> None: + with self._conn() as conn: + conn.execute( + """ + INSERT INTO messages + (thread_id, from_key, to_key, direction, message_id_header, + in_reply_to, subject, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + thread_id, + from_key, + to_key, + direction, + message_id_header, + in_reply_to, + subject, + status, + _now(), + ), + ) + + def mark_status(self, message_id_header: str, status: str) -> None: + with self._conn() as conn: + conn.execute( + "UPDATE messages SET status = ? WHERE message_id_header = ?", + (status, message_id_header), + ) + + def already_seen(self, message_id_header: str) -> bool: + with self._conn() as conn: + row = conn.execute( + "SELECT 1 FROM messages WHERE message_id_header = ? LIMIT 1", + (message_id_header,), + ).fetchone() + return row is not None + + def already_replied(self, in_reply_to: str) -> bool: + with self._conn() as conn: + row = conn.execute( + "SELECT 1 FROM messages WHERE in_reply_to = ? AND direction = 'sent' LIMIT 1", + (in_reply_to,), + ).fetchone() + return row is not None + + # -- reply queue -------------------------------------------------------- + def thread_id_for(self, in_reply_to: str | None) -> str: + """Resolve the thread a message belongs to, or start a new one.""" + if in_reply_to: + with self._conn() as conn: + row = conn.execute( + "SELECT thread_id FROM messages WHERE message_id_header = ? LIMIT 1", + (in_reply_to,), + ).fetchone() + if row: + return row[0] + return str(__import__("uuid").uuid4()) + + def queue_reply( + self, + *, + message_id_header: str, + thread_id: str, + mailbox_key: str, + sender_key: str, + subject: str, + references_header: str | None, + scheduled_at: str, + ) -> None: + with self._conn() as conn: + conn.execute( + """ + INSERT OR IGNORE INTO reply_queue + (message_id_header, thread_id, mailbox_key, sender_key, + subject, references_header, scheduled_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + message_id_header, + thread_id, + mailbox_key, + sender_key, + subject, + references_header, + scheduled_at, + _now(), + ), + ) + + def due_replies(self) -> list[sqlite3.Row]: + now = _now() + with self._conn() as conn: + conn.row_factory = sqlite3.Row + rows = conn.execute( + "SELECT * FROM reply_queue WHERE scheduled_at <= ? ORDER BY scheduled_at", + (now,), + ).fetchall() + return rows + + def clear_queued_reply(self, message_id_header: str) -> None: + with self._conn() as conn: + conn.execute("DELETE FROM reply_queue WHERE message_id_header = ?", (message_id_header,)) + + def is_reply_queued(self, message_id_header: str) -> bool: + with self._conn() as conn: + row = conn.execute( + "SELECT 1 FROM reply_queue WHERE message_id_header = ? LIMIT 1", + (message_id_header,), + ).fetchone() + return row is not None + + def daily_summary(self) -> dict: + today = date.today().isoformat() + with self._conn() as conn: + sent = conn.execute( + "SELECT COUNT(*) FROM messages WHERE direction = 'sent' AND created_at LIKE ?", + (f"{today}%",), + ).fetchone()[0] + replied = conn.execute( + "SELECT COUNT(*) FROM messages WHERE direction = 'sent' AND in_reply_to IS NOT NULL AND created_at LIKE ?", + (f"{today}%",), + ).fetchone()[0] + unspammed = conn.execute( + "SELECT COUNT(*) FROM messages WHERE status = 'unspammed' AND created_at LIKE ?", + (f"{today}%",), + ).fetchone()[0] + bounced = conn.execute( + "SELECT COUNT(*) FROM messages WHERE status = 'bounced' AND created_at LIKE ?", + (f"{today}%",), + ).fetchone()[0] + return {"sent": sent, "replied": replied, "unspammed": unspammed, "bounced": bounced} diff --git a/hearth/watcher.py b/hearth/watcher.py new file mode 100644 index 0000000..9bb7aa2 --- /dev/null +++ b/hearth/watcher.py @@ -0,0 +1,190 @@ +"""IMAP polling for full_duplex mailboxes. + +Only ever looks at mail from senders already on the allow-list (matched by +exact email address via `Config.is_known_email`). Anything from an address +that isn't a configured participant is left completely untouched — Hearth +does not read, move, or react to a stranger's email. +""" +from __future__ import annotations + +import imaplib +import logging +import random +from datetime import datetime, timedelta, timezone +from email import message_from_bytes +from email.utils import parseaddr + +from hearth import mailer +from hearth.composer import compose_reply +from hearth.config import Config +from hearth.settings import settings +from hearth.store import Store + +log = logging.getLogger("hearth.watcher") + +SPAM_FOLDER_CANDIDATES = ["[Gmail]/Spam", "Spam", "Junk", "INBOX.Junk", "INBOX.Spam"] + + +def _decode(value) -> str: + if value is None: + return "" + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return str(value) + + +def _random_reply_delay() -> timedelta: + minutes = random.randint(settings.min_reply_delay_min, settings.max_reply_delay_min) + return timedelta(minutes=minutes) + + +def _open_imap(participant) -> imaplib.IMAP4: + imap = imaplib.IMAP4_SSL(participant.imap_host, participant.imap_port) + imap.login(participant.username, participant.password) + return imap + + +def _fetch_unseen(imap: imaplib.IMAP4, folder: str) -> list[bytes]: + typ, _ = imap.select(folder, readonly=False) + if typ != "OK": + return [] + typ, data = imap.search(None, "UNSEEN") + if typ != "OK" or not data or not data[0]: + return [] + return data[0].split() + + +def _handle_message(config: Config, store: Store, mailbox_key: str, raw: bytes, *, from_spam: bool) -> None: + msg = message_from_bytes(raw) + _, from_addr = parseaddr(_decode(msg.get("From"))) + sender_key = config.is_known_email(from_addr) + if not sender_key: + return # not a pool participant — never touched + if sender_key == mailbox_key: + return # our own mailbox talking to itself, ignore + + message_id = _decode(msg.get("Message-ID")).strip() + if not message_id: + return + if store.already_seen(message_id) or store.is_reply_queued(message_id): + return + + in_reply_to = _decode(msg.get("In-Reply-To")).strip() or None + subject = _decode(msg.get("Subject")).strip() or "(no subject)" + references = _decode(msg.get("References")).strip() or in_reply_to + + thread_id = store.thread_id_for(in_reply_to) + store.record_message( + thread_id=thread_id, + from_key=sender_key, + to_key=mailbox_key, + direction="received", + message_id_header=message_id, + in_reply_to=in_reply_to, + subject=subject, + status="unspammed" if from_spam else "received", + ) + + if store.already_replied(message_id): + return + + scheduled_at = (datetime.now(timezone.utc) + _random_reply_delay()).isoformat() + store.queue_reply( + message_id_header=message_id, + thread_id=thread_id, + mailbox_key=mailbox_key, + sender_key=sender_key, + subject=subject, + references_header=references, + scheduled_at=scheduled_at, + ) + log.info( + "queued reply: %s -> %s (subject=%r, in %s)", + mailbox_key, + sender_key, + subject, + "spam->inbox" if from_spam else "inbox", + ) + + +def poll_mailbox(config: Config, store: Store, mailbox_key: str) -> None: + participant = config.get(mailbox_key) + if not participant.can_authenticate: + return + + try: + imap = _open_imap(participant) + except Exception: + log.exception("IMAP connect failed for %s", mailbox_key) + return + + try: + for uid in _fetch_unseen(imap, "INBOX"): + typ, data = imap.fetch(uid, "(RFC822)") + if typ == "OK" and data and data[0]: + _handle_message(config, store, mailbox_key, data[0][1], from_spam=False) + + for folder in SPAM_FOLDER_CANDIDATES: + try: + uids = _fetch_unseen(imap, folder) + except Exception: + continue + for uid in uids: + typ, data = imap.fetch(uid, "(RFC822)") + if typ != "OK" or not data or not data[0]: + continue + raw = data[0][1] + _handle_message(config, store, mailbox_key, raw, from_spam=True) + if not settings.dry_run and settings.enabled: + try: + imap.copy(uid, "INBOX") + imap.store(uid, "+FLAGS", "\\Deleted") + except Exception: + log.exception("failed to move message out of %s for %s", folder, mailbox_key) + if uids: + imap.expunge() + finally: + try: + imap.close() + imap.logout() + except Exception: + pass + + +def poll_all(config: Config, store: Store) -> None: + for mailbox_key in config.full_duplex_keys(): + poll_mailbox(config, store, mailbox_key) + + +def send_due_replies(config: Config, store: Store) -> int: + sent = 0 + for row in store.due_replies(): + mailbox = config.get(row["mailbox_key"]) + sender = config.get(row["sender_key"]) + subject, body = compose_reply(row["subject"], sender.display_name, sender.email) + try: + result = mailer.send( + config, + sender_key=row["mailbox_key"], + recipient_key=row["sender_key"], + subject=subject, + body=body, + in_reply_to=row["message_id_header"], + references=row["references_header"], + ) + except Exception: + log.exception("failed to send queued reply %s -> %s", mailbox.email, sender.email) + continue + + store.record_message( + thread_id=row["thread_id"], + from_key=row["mailbox_key"], + to_key=row["sender_key"], + direction="sent", + message_id_header=result.message_id, + in_reply_to=row["message_id_header"], + subject=subject, + ) + store.clear_queued_reply(row["message_id_header"]) + sent += 1 + return sent diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..152b054 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest==8.3.4 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2c75570 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +pydantic==2.10.3 +pydantic-settings==2.6.1 +PyYAML==6.0.2 +httpx==0.28.1 +tzdata==2024.2 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..648f1b8 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,102 @@ +import textwrap + +import pytest + +from hearth.config import ConfigError, load_config + +VALID_YAML = textwrap.dedent( + """ + participants: + alerts: + tier: full_duplex + email: alerts@levkine.ca + display_name: "Ilia" + imap_host: mail.levkine.ca + smtp_host: mail.levkine.ca + username: alerts@levkine.ca + password_env: TEST_PASSWORD_ALERTS + friend: + tier: send_only + email: friend@example.com + display_name: "Friend" + """ +) + + +def write_yaml(tmp_path, content): + path = tmp_path / "hearth.yml" + path.write_text(content) + return path + + +def test_load_valid_config(tmp_path): + path = write_yaml(tmp_path, VALID_YAML) + config = load_config(path) + assert set(config.all_keys()) == {"alerts", "friend"} + assert config.full_duplex_keys() == ["alerts"] + assert config.is_known_email("ALERTS@levkine.ca") == "alerts" + assert config.is_known_email("nobody@nowhere.com") is None + + +def test_missing_file_raises(tmp_path): + with pytest.raises(ConfigError): + load_config(tmp_path / "does-not-exist.yml") + + +def test_duplicate_email_rejected(tmp_path): + content = textwrap.dedent( + """ + participants: + alerts: + tier: full_duplex + email: alerts@levkine.ca + imap_host: mail.levkine.ca + smtp_host: mail.levkine.ca + username: alerts@levkine.ca + password_env: TEST_PASSWORD_ALERTS + duplicate: + tier: send_only + email: alerts@levkine.ca + display_name: "Dup" + """ + ) + path = write_yaml(tmp_path, content) + with pytest.raises(ConfigError): + load_config(path) + + +def test_full_duplex_requires_creds_fields(tmp_path): + content = textwrap.dedent( + """ + participants: + broken: + tier: full_duplex + email: broken@levkine.ca + """ + ) + path = write_yaml(tmp_path, content) + with pytest.raises(ConfigError): + load_config(path) + + +def test_requires_at_least_one_full_duplex(tmp_path): + content = textwrap.dedent( + """ + participants: + friend: + tier: send_only + email: friend@example.com + """ + ) + path = write_yaml(tmp_path, content) + with pytest.raises(ConfigError): + load_config(path) + + +def test_send_only_participant_cannot_authenticate(tmp_path): + path = write_yaml(tmp_path, VALID_YAML) + config = load_config(path) + friend = config.get("friend") + assert friend.can_authenticate is False + with pytest.raises(ConfigError): + _ = friend.password diff --git a/tests/test_mailer_guard.py b/tests/test_mailer_guard.py new file mode 100644 index 0000000..e2c2fbd --- /dev/null +++ b/tests/test_mailer_guard.py @@ -0,0 +1,87 @@ +import os +import textwrap + +import pytest + +from hearth import mailer +from hearth.config import ConfigError, load_config +from hearth.settings import settings + +YAML = textwrap.dedent( + """ + participants: + alerts: + tier: full_duplex + email: alerts@levkine.ca + imap_host: mail.levkine.ca + smtp_host: mail.levkine.ca + username: alerts@levkine.ca + password_env: TEST_PASSWORD_ALERTS + gmail: + tier: full_duplex + email: idobkin@gmail.com + imap_host: imap.gmail.com + smtp_host: smtp.gmail.com + username: idobkin@gmail.com + password_env: TEST_PASSWORD_GMAIL + friend: + tier: send_only + email: friend@example.com + """ +) + + +@pytest.fixture(autouse=True) +def _force_dry_run(): + original = settings.dry_run + settings.dry_run = True + yield + settings.dry_run = original + + +@pytest.fixture +def config(tmp_path): + os.environ["TEST_PASSWORD_ALERTS"] = "x" + os.environ["TEST_PASSWORD_GMAIL"] = "x" + path = tmp_path / "hearth.yml" + path.write_text(YAML) + return load_config(path) + + +def test_send_to_known_participant_succeeds_dry_run(config): + result = mailer.send( + config, + sender_key="alerts", + recipient_key="friend", + subject="hi", + body="hello", + ) + assert result.dry_run is True + + +def test_cannot_send_to_unknown_recipient_key(config): + with pytest.raises(ConfigError): + mailer.send( + config, + sender_key="alerts", + recipient_key="random_stranger", + subject="hi", + body="hello", + ) + + +def test_cannot_send_as_send_only_participant(config): + with pytest.raises(mailer.SendBlocked): + mailer.send( + config, + sender_key="friend", + recipient_key="alerts", + subject="hi", + body="hello", + ) + + +def test_is_known_email_is_exact_match_only(config): + # A lookalike/subdomain address must never resolve to a real participant. + assert config.is_known_email("alerts@levkine.ca.evil.com") is None + assert config.is_known_email("not-friend@example.com") is None