From 8a994a1fd43b15dae112af540cc0ff0a5bb8ae2d Mon Sep 17 00:00:00 2001 From: ilia Date: Sat, 18 Jul 2026 15:29:56 -0400 Subject: [PATCH] Hold auto-replies to the business-hours window. Queued replies that come due overnight stay in SQLite until the window opens, matching new-thread gating. Also add .gitleaks.toml so the global pre-commit hook can run. --- README.md | 4 +- hearth/scheduler.py | 7 +-- hearth/watcher.py | 7 +++ tests/test_watcher_hours.py | 98 +++++++++++++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 tests/test_watcher_hours.py diff --git a/README.md b/README.md index 6a97071..1b67cfe 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ never originate new warmup threads — they only receive and reply. 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 run** — `HEARTH_ENABLED=false` stops everything immediately; `--dry-run` logs every intended action without sending or modifying any mailbox. @@ -97,7 +99,7 @@ Run the test suite (includes the allow-list guardrail tests): | `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/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`) | diff --git a/hearth/scheduler.py b/hearth/scheduler.py index 45517c8..ce03985 100644 --- a/hearth/scheduler.py +++ b/hearth/scheduler.py @@ -1,6 +1,7 @@ """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). +handled separately in watcher.send_due_replies (delay-randomized, and gated +on the same business-hours window via in_business_hours/now_local below). """ from __future__ import annotations @@ -24,7 +25,7 @@ def daily_cap(store: Store) -> int: return max(1, int(round(min(cap, settings.daily_cap_max)))) -def _now() -> datetime: +def now_local() -> datetime: try: return datetime.now(ZoneInfo(settings.timezone)) except Exception: @@ -49,7 +50,7 @@ def maybe_start_new_thread(config: Config, store: Store) -> bool: log.debug("HEARTH_ENABLED=false — skipping send cycle") return False - now = _now() + now = now_local() if not in_business_hours(now): return False diff --git a/hearth/watcher.py b/hearth/watcher.py index 5e1842f..7d7e339 100644 --- a/hearth/watcher.py +++ b/hearth/watcher.py @@ -17,6 +17,7 @@ from email.utils import parseaddr from hearth import mailer from hearth.composer import compose_reply from hearth.config import Config +from hearth.scheduler import in_business_hours, now_local from hearth.settings import settings from hearth.store import Store @@ -173,6 +174,12 @@ def poll_all(config: Config, store: Store) -> None: def send_due_replies(config: Config, store: Store) -> int: + # Replies wait for the same business-hours window as new threads: a due + # reply stays queued (due_replies keeps returning it) until the window + # opens, so nothing is lost — just deferred to a human-plausible hour. + if not in_business_hours(now_local()): + return 0 + sent = 0 for row in store.due_replies(): mailbox = config.get(row["mailbox_key"]) diff --git a/tests/test_watcher_hours.py b/tests/test_watcher_hours.py new file mode 100644 index 0000000..891ad78 --- /dev/null +++ b/tests/test_watcher_hours.py @@ -0,0 +1,98 @@ +import textwrap +from datetime import datetime +from unittest import mock + +import pytest + +from hearth import watcher +from hearth.config import load_config +from hearth.settings import settings + +YAML = textwrap.dedent( + """ + participants: + ilia: + tier: full_duplex + email: ilia@levkine.ca + imap_host: mail.example.com + smtp_host: mail.example.com + username: ilia@levkine.ca + password_env: TEST_PASSWORD_ILIA + friend: + tier: send_only + email: friend@example.com + """ +) + + +@pytest.fixture +def config(tmp_path, monkeypatch): + monkeypatch.setenv("TEST_PASSWORD_ILIA", "x") + path = tmp_path / "hearth.yml" + path.write_text(YAML, encoding="utf-8") + return load_config(path) + + +def _due_reply_row(): + return { + "mailbox_key": "ilia", + "sender_key": "friend", + "subject": "hi", + "message_id_header": "", + "references_header": "", + "thread_id": "t1", + } + + +def _store_with_due_reply(): + store = mock.Mock() + store.due_replies.return_value = [_due_reply_row()] + return store + + +def test_replies_held_outside_business_hours(config, monkeypatch): + monkeypatch.setattr(settings, "business_hours_start", 8) + monkeypatch.setattr(settings, "business_hours_end", 20) + monkeypatch.setattr(settings, "active_weekdays_only", False) + # 3 AM on a Wednesday — outside the window. + monkeypatch.setattr(watcher, "now_local", lambda: datetime(2026, 7, 15, 3, 0)) + + store = _store_with_due_reply() + send = mock.Mock() + monkeypatch.setattr(watcher.mailer, "send", send) + + assert watcher.send_due_replies(config, store) == 0 + send.assert_not_called() + store.clear_queued_reply.assert_not_called() + + +def test_replies_sent_inside_business_hours(config, monkeypatch): + monkeypatch.setattr(settings, "business_hours_start", 8) + monkeypatch.setattr(settings, "business_hours_end", 20) + monkeypatch.setattr(settings, "active_weekdays_only", False) + # 10 AM on a Wednesday — inside the window. + monkeypatch.setattr(watcher, "now_local", lambda: datetime(2026, 7, 15, 10, 0)) + monkeypatch.setattr(watcher, "compose_reply", lambda *a: ("subj", "body")) + + store = _store_with_due_reply() + send = mock.Mock(return_value=mock.Mock(message_id="", dry_run=False)) + monkeypatch.setattr(watcher.mailer, "send", send) + + assert watcher.send_due_replies(config, store) == 1 + send.assert_called_once() + store.clear_queued_reply.assert_called_once_with("") + + +def test_replies_held_on_weekend_when_weekdays_only(config, monkeypatch): + monkeypatch.setattr(settings, "business_hours_start", 8) + monkeypatch.setattr(settings, "business_hours_end", 20) + monkeypatch.setattr(settings, "active_weekdays_only", True) + # Saturday noon — inside hours but weekends disabled. + monkeypatch.setattr(watcher, "now_local", lambda: datetime(2026, 7, 18, 12, 0)) + + store = _store_with_due_reply() + send = mock.Mock() + monkeypatch.setattr(watcher.mailer, "send", send) + + assert watcher.send_due_replies(config, store) == 0 + send.assert_not_called()