From 9ba9851f6d93faea6054aa03a6703f53516ccbd5 Mon Sep 17 00:00:00 2001 From: jay Date: Tue, 9 Jun 2026 13:24:34 -0400 Subject: [PATCH] Feedback reply: Reply-To header routes reader replies to our inbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Codex: outgoing reply now sets Reply-To = GOODNEWS_REPLY_TO_EMAIL, falling back to the From address. Never the reader's own address (they're the recipient). send_email gained an optional reply_to param. Failed-send stays UI-only (draft kept) — no schema change, per Codex's lean. Co-Authored-By: Claude Opus 4.8 (1M context) --- goodnews/email_send.py | 11 +++++++-- tests/test_email_reply_to.py | 48 ++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 tests/test_email_reply_to.py diff --git a/goodnews/email_send.py b/goodnews/email_send.py index 58fd04e..5e6a68a 100644 --- a/goodnews/email_send.py +++ b/goodnews/email_send.py @@ -25,10 +25,12 @@ def _cfg() -> dict: "user": os.environ.get("GOODNEWS_SMTP_USER", ""), "password": os.environ.get("GOODNEWS_SMTP_PASSWORD", ""), "sender": os.environ.get("GOODNEWS_SMTP_FROM", "Upbeat Bytes "), + # Where a reader's reply should land; falls back to the From address. + "reply_to": os.environ.get("GOODNEWS_REPLY_TO_EMAIL", ""), } -def send_email(to: str, subject: str, text: str, html: str | None = None) -> None: +def send_email(to: str, subject: str, text: str, html: str | None = None, reply_to: str | None = None) -> None: """Send one message. Raises on failure (caller decides how loud to be).""" cfg = _cfg() if not cfg["host"]: @@ -36,6 +38,8 @@ def send_email(to: str, subject: str, text: str, html: str | None = None) -> Non msg = EmailMessage() msg["From"] = cfg["sender"] msg["To"] = to + if reply_to: + msg["Reply-To"] = reply_to msg["Subject"] = subject msg.set_content(text) if html: @@ -87,7 +91,10 @@ def send_feedback_reply(to: str, reply_text: str, reply_html: str | None, origin f'
{oq}
' "

Thanks for reaching out.
— Upbeat Bytes

" ) - send_email(to, subject, text, html=body_html) + # Route the reader's reply to our chosen inbox (never back to the reader). + cfg = _cfg() + reply_to = cfg["reply_to"] or cfg["sender"] + send_email(to, subject, text, html=body_html, reply_to=reply_to) def send_magic_link(to: str, link: str) -> None: diff --git a/tests/test_email_reply_to.py b/tests/test_email_reply_to.py new file mode 100644 index 0000000..f732fce --- /dev/null +++ b/tests/test_email_reply_to.py @@ -0,0 +1,48 @@ +import goodnews.email_send as es + + +class _FakeSMTP: + last = {} + + def __init__(self, *a, **k): + pass + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def ehlo(self): + pass + + def starttls(self, context=None): + pass + + def login(self, *a): + pass + + def send_message(self, msg): + _FakeSMTP.last = {"To": msg["To"], "Reply-To": msg["Reply-To"], "From": msg["From"]} + + +def _arm(monkeypatch): + monkeypatch.setenv("GOODNEWS_SMTP_HOST", "smtp.example.com") + monkeypatch.setattr(es.smtplib, "SMTP", _FakeSMTP) + + +def test_reply_to_uses_env_inbox(monkeypatch): + _arm(monkeypatch) + monkeypatch.setenv("GOODNEWS_REPLY_TO_EMAIL", "inbox@upbeatbytes.com") + es.send_feedback_reply("reader@x.com", "hello", "

hello

", "original?") + assert _FakeSMTP.last["Reply-To"] == "inbox@upbeatbytes.com" + assert _FakeSMTP.last["To"] == "reader@x.com" # reader is recipient + assert _FakeSMTP.last["Reply-To"] != "reader@x.com" # never the reader + + +def test_reply_to_falls_back_to_from(monkeypatch): + _arm(monkeypatch) + monkeypatch.delenv("GOODNEWS_REPLY_TO_EMAIL", raising=False) + monkeypatch.setenv("GOODNEWS_SMTP_FROM", "Upbeat Bytes ") + es.send_feedback_reply("reader@x.com", "hi", None, "o") + assert _FakeSMTP.last["Reply-To"] == "Upbeat Bytes "