Scrub personal addresses from examples, tests, and intro email docs.

Real participants stay in gitignored hearth.yml/.env on the deploy host only.
This commit is contained in:
ilia 2026-07-09 20:44:29 -04:00
parent 9370e768c8
commit b0db0a26e3
6 changed files with 66 additions and 63 deletions

View File

@ -55,7 +55,7 @@ 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
HEARTH_PASSWORD_MAILBOX_A=... .venv/bin/python -m hearth.main --once --dry-run
```
Run the test suite (includes the allow-list guardrail tests):

View File

@ -8,6 +8,9 @@ the individual messages themselves will just read as normal short emails.
Don't add anyone to `hearth.yml` until they've actually seen this and said
yes.
Real participant addresses live only in the gitignored `hearth.yml` on the
deploy host — never commit them.
---
## Copy-paste template
@ -16,10 +19,10 @@ yes.
>
> Hey [name],
>
> Random favor to ask. I run my own email server for `levkine.ca` (the
> "IT nerd homelab" thing), and small self-hosted mail servers like mine
> have a real problem: Gmail/Outlook/Yahoo don't trust them by default, so
> my emails sometimes land in spam even for real messages.
> Random favor to ask. I run my own email server (the "IT nerd homelab"
> thing), and small self-hosted mail servers like mine have a real problem:
> Gmail/Outlook/Yahoo don't trust them by default, so my emails sometimes
> land in spam even for real messages.
>
> The fix is basically just... using it normally. The more genuine
> back-and-forth email traffic my addresses have — people actually opening,
@ -28,8 +31,8 @@ yes.
>
> So here's the ask: every so often (probably a couple times a week at
> most, starting slower), you might get a short, casual email from one of
> my addresses (`ilia@levkine.ca`, possibly others later). Nothing weird —
> just a "hey, checking in, how's it going" type message. If you could:
> my addresses. Nothing weird — just a "hey, checking in, how's it going"
> type message. If you could:
>
> - **Reply normally**, like you would to a text — doesn't need to be long
> - **Mark it "Not Spam"** if it ever lands in your Spam/Junk folder instead
@ -42,7 +45,7 @@ yes.
>
> Let me know if you're in!
>
> — Ilia
> — [your name]
---
@ -55,8 +58,8 @@ yes.
text message, not a newsletter.
- **Timing:** business hours only, your local time zone, spread out rather
than bursts.
- **Senders:** real mailboxes I actually control (`@levkine.ca` addresses,
or my own personal Gmail) — never a spoofed/unrelated identity.
- **Senders:** real mailboxes I actually control — never a spoofed/unrelated
identity.
- **Never:** links, attachments, requests for money/info/action, anything
that reads like marketing.

View File

@ -31,8 +31,8 @@ HEARTH_MAX_REPLY_DELAY_MIN=180
HEARTH_LLM_URL=
HEARTH_LLM_MODEL=llama3.1
# Optional: daily summary via your existing ntfy instance
NTFY_URL=https://push.levkin.ca
# Optional: daily summary via ntfy
NTFY_URL=
NTFY_PUBLISH_TOKEN=
NTFY_TOPIC=hearth-digest
@ -40,5 +40,5 @@ NTFY_TOPIC=hearth-digest
# One HEARTH_PASSWORD_<KEY> 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_ILIA=
HEARTH_PASSWORD_PERSONAL_GMAIL=
HEARTH_PASSWORD_MAILBOX_A=
HEARTH_PASSWORD_PERSONAL_INBOX=

View File

@ -9,7 +9,7 @@
# pairs before turning on the full pool. Omit `active` entirely (default) to
# make everyone below active.
#
# active: [ilia, personal_gmail]
# active: [mailbox_a, personal_inbox]
#
# tier: full_duplex
# Your own mailboxes. Hearth logs in via IMAP+SMTP, sends new threads,
@ -17,34 +17,34 @@
# username + a HEARTH_PASSWORD_<KEY> env var (see env.example).
#
# tier: send_only
# A friend/coworker who explicitly agreed to help warm up levkine.ca.
# A friend/coworker who explicitly agreed to help warm up the domain.
# 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:
ilia:
mailbox_a:
tier: full_duplex
email: ilia@levkine.ca
display_name: "Ilia"
imap_host: mail.levkine.ca
email: mailbox-a@example.com
display_name: "You"
imap_host: mail.example.com
imap_port: 993
smtp_host: mail.levkine.ca
smtp_host: mail.example.com
smtp_port: 587
username: ilia@levkine.ca
password_env: HEARTH_PASSWORD_ILIA
username: mailbox-a@example.com
password_env: HEARTH_PASSWORD_MAILBOX_A
personal_gmail:
personal_inbox:
tier: full_duplex
email: idobkin@gmail.com
display_name: "Ilia"
imap_host: imap.gmail.com
email: you@example.com
display_name: "You"
imap_host: imap.example.com
imap_port: 993
smtp_host: smtp.gmail.com
smtp_host: smtp.example.com
smtp_port: 587
username: idobkin@gmail.com
password_env: HEARTH_PASSWORD_PERSONAL_GMAIL
username: you@example.com
password_env: HEARTH_PASSWORD_PERSONAL_INBOX
# 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

View File

@ -9,11 +9,11 @@ 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
email: alerts@example.com
display_name: "Ops"
imap_host: mail.example.com
smtp_host: mail.example.com
username: alerts@example.com
password_env: TEST_PASSWORD_ALERTS
friend:
tier: send_only
@ -34,7 +34,7 @@ def test_load_valid_config(tmp_path):
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("ALERTS@example.com") == "alerts"
assert config.is_known_email("nobody@nowhere.com") is None
@ -49,14 +49,14 @@ def test_duplicate_email_rejected(tmp_path):
participants:
alerts:
tier: full_duplex
email: alerts@levkine.ca
imap_host: mail.levkine.ca
smtp_host: mail.levkine.ca
username: alerts@levkine.ca
email: alerts@example.com
imap_host: mail.example.com
smtp_host: mail.example.com
username: alerts@example.com
password_env: TEST_PASSWORD_ALERTS
duplicate:
tier: send_only
email: alerts@levkine.ca
email: alerts@example.com
display_name: "Dup"
"""
)
@ -71,7 +71,7 @@ def test_full_duplex_requires_creds_fields(tmp_path):
participants:
broken:
tier: full_duplex
email: broken@levkine.ca
email: broken@example.com
"""
)
path = write_yaml(tmp_path, content)
@ -100,10 +100,10 @@ def test_active_list_restricts_to_subset(tmp_path):
participants:
alerts:
tier: full_duplex
email: alerts@levkine.ca
imap_host: mail.levkine.ca
smtp_host: mail.levkine.ca
username: alerts@levkine.ca
email: alerts@example.com
imap_host: mail.example.com
smtp_host: mail.example.com
username: alerts@example.com
password_env: TEST_PASSWORD_ALERTS
friend:
tier: send_only
@ -127,10 +127,10 @@ def test_active_list_with_unknown_key_raises(tmp_path):
participants:
alerts:
tier: full_duplex
email: alerts@levkine.ca
imap_host: mail.levkine.ca
smtp_host: mail.levkine.ca
username: alerts@levkine.ca
email: alerts@example.com
imap_host: mail.example.com
smtp_host: mail.example.com
username: alerts@example.com
password_env: TEST_PASSWORD_ALERTS
"""
)
@ -146,10 +146,10 @@ def test_active_list_must_keep_a_full_duplex_participant(tmp_path):
participants:
alerts:
tier: full_duplex
email: alerts@levkine.ca
imap_host: mail.levkine.ca
smtp_host: mail.levkine.ca
username: alerts@levkine.ca
email: alerts@example.com
imap_host: mail.example.com
smtp_host: mail.example.com
username: alerts@example.com
password_env: TEST_PASSWORD_ALERTS
friend:
tier: send_only

View File

@ -12,17 +12,17 @@ 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
email: alerts@example.com
imap_host: mail.example.com
smtp_host: mail.example.com
username: alerts@example.com
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
email: you@example.com
imap_host: imap.example.com
smtp_host: smtp.example.com
username: you@example.com
password_env: TEST_PASSWORD_GMAIL
friend:
tier: send_only
@ -83,5 +83,5 @@ def test_cannot_send_as_send_only_participant(config):
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("alerts@example.com.evil.com") is None
assert config.is_known_email("not-friend@example.com") is None