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.
This commit is contained in:
ilia 2026-07-18 15:29:56 -04:00
parent 1aad2232bd
commit 8a994a1fd4
4 changed files with 112 additions and 4 deletions

View File

@ -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`) |

View File

@ -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

View File

@ -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"])

View File

@ -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": "<m1>",
"references_header": "<m1>",
"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="<r1>", 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("<m1>")
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()