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:
parent
1aad2232bd
commit
8a994a1fd4
@ -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`) |
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"])
|
||||
|
||||
98
tests/test_watcher_hours.py
Normal file
98
tests/test_watcher_hours.py
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user