From 8f96f86463180b76e4b66b97862f8274377b8425 Mon Sep 17 00:00:00 2001
From: alan
Date: Tue, 11 Aug 2026 08:58:44 -0500
Subject: [PATCH 1/6] fix(email): harden inbound reply processing against the
failures found in the prod investigation
Investigation of the dead reply-to-review flow (2026-08-11) found latent
defects that would break or undermine the pipeline even once its missing
AWS/DNS infrastructure is provisioned:
- The SEC-5 anti-spoofing gate merged verdicts across ALL
Authentication-Results headers with "a pass wins", so a sender-forged
pass header overrode SES's fail verdicts. Now only the topmost header
(the one SES prepends on receipt) is trusted, and it must carry the
amazonses.com authserv-id.
- HTML-only replies (no text/plain part) extracted an empty body and were
silently dropped. Now fall back to tag-stripped HTML, with structural
quote removal (blockquote/gmail_quote).
- Auto-submitted mail (RFC 3834, e.g. out-of-office) was processed and
could be answered with a help email - a mail loop. Now ignored.
- MAX_REPLIES_PER_TOKEN_PER_HOUR was declared but never enforced. Now a
sliding one-hour in-memory window per token.
- A poison S3 object was retried every poll forever. Now quarantined to
failed/ after 3 attempts for manual inspection.
Co-Authored-By: Claude Fable 5
---
src/services/email_inbound.py | 136 ++++++++++-
tests/unit/test_email_inbound_hardening.py | 255 +++++++++++++++++++++
2 files changed, 379 insertions(+), 12 deletions(-)
create mode 100644 tests/unit/test_email_inbound_hardening.py
diff --git a/src/services/email_inbound.py b/src/services/email_inbound.py
index a1c0fc6..ba9b189 100644
--- a/src/services/email_inbound.py
+++ b/src/services/email_inbound.py
@@ -25,6 +25,39 @@
# Rate limit: max replies per token per hour
MAX_REPLIES_PER_TOKEN_PER_HOUR = 10
+# Processing attempts per S3 object before it is quarantined under failed/.
+MAX_S3_PROCESS_ATTEMPTS = 3
+
+# token -> recent reply timestamps (monotonic-ish epoch seconds).
+_RECENT_REPLY_TIMES: dict[str, list[float]] = {}
+
+# s3 key -> consecutive processing failures (in-memory; resets on restart).
+_S3_FAILURE_COUNTS: dict[str, int] = {}
+
+
+def _reply_rate_ok(token: str, now: float | None = None) -> bool:
+ """Sliding one-hour window per reply token, capped at
+ MAX_REPLIES_PER_TOKEN_PER_HOUR. In-memory: the worker is a single
+ long-lived process, and a restart merely resets the window."""
+ import time
+
+ ts = time.time() if now is None else now
+ window = [t for t in _RECENT_REPLY_TIMES.get(token, []) if ts - t < 3600]
+ if len(window) >= MAX_REPLIES_PER_TOKEN_PER_HOUR:
+ _RECENT_REPLY_TIMES[token] = window
+ return False
+ window.append(ts)
+ _RECENT_REPLY_TIMES[token] = window
+ return True
+
+
+def _is_auto_submitted(msg: email.message.Message) -> bool:
+ """RFC 3834: any Auto-Submitted value other than "no" marks auto-generated
+ mail (out-of-office replies, list expansions). Processing those — and
+ answering them with a help email — is how mail loops start."""
+ auto = (msg.get("Auto-Submitted") or "").strip().lower()
+ return bool(auto) and auto != "no" and not auto.startswith("no ")
+
# Auth verdicts (from the SES-stamped Authentication-Results header) that mean
# the message failed a check — any of these on spf/dkim/dmarc rejects the reply.
# ("none" is intentionally excluded: it means the sender domain publishes no
@@ -50,13 +83,26 @@ def _authentication_results_ok(msg: email.message.Message) -> bool:
logger.warning("Rejecting inbound reply: no Authentication-Results header")
return False
+ # Trust ONLY the topmost header. SES prepends its own Authentication-
+ # Results on receipt, so a sender-forged header always sits below it —
+ # merging verdicts across all headers ("a pass wins") let a self-stamped
+ # spf=pass override SES's spf=fail. The topmost header must also carry
+ # SES's authserv-id: anything else did not transit our SES receipt path.
+ header = headers[0]
+ authserv_id = header.split(";", 1)[0].strip().lower()
+ if authserv_id != "amazonses.com":
+ logger.warning(
+ "Rejecting inbound reply: topmost Authentication-Results is from %r, "
+ "not amazonses.com",
+ authserv_id,
+ )
+ return False
+
verdicts: dict[str, str] = {}
- for header in headers:
- for mech, result in _AUTH_VERDICT_RE.findall(header):
- mech_l, result_l = mech.lower(), result.lower()
- # Keep the strongest verdict seen for each mechanism (a pass wins).
- if mech_l not in verdicts or result_l == "pass":
- verdicts[mech_l] = result_l
+ for mech, result in _AUTH_VERDICT_RE.findall(header):
+ # First occurrence wins: the leading verdict is the mechanism's result;
+ # later matches can come from propagated or commented values.
+ verdicts.setdefault(mech.lower(), result.lower())
for mech in ("spf", "dkim", "dmarc"):
if verdicts.get(mech) in _AUTH_FAIL_VERDICTS:
@@ -108,10 +154,33 @@ async def poll_inbound_emails(session_factory: async_sessionmaker) -> int:
# Delete processed email from S3
s3.delete_object(Bucket=bucket, Key=key)
+ _S3_FAILURE_COUNTS.pop(key, None)
processed += 1
except Exception as exc:
logger.error("Error processing inbound email %s: %s", key, exc, exc_info=True)
+ # A poison message would otherwise be retried every poll
+ # forever. After MAX_S3_PROCESS_ATTEMPTS consecutive failures,
+ # quarantine it under failed/ (outside the polled prefix) for
+ # manual inspection. The counter is in-memory, so a restart
+ # grants a fresh round of attempts — acceptable.
+ _S3_FAILURE_COUNTS[key] = _S3_FAILURE_COUNTS.get(key, 0) + 1
+ if _S3_FAILURE_COUNTS[key] >= MAX_S3_PROCESS_ATTEMPTS:
+ try:
+ failed_key = "failed/" + key.removeprefix(prefix)
+ s3.copy_object(
+ Bucket=bucket,
+ CopySource={"Bucket": bucket, "Key": key},
+ Key=failed_key,
+ )
+ s3.delete_object(Bucket=bucket, Key=key)
+ _S3_FAILURE_COUNTS.pop(key, None)
+ logger.error(
+ "Quarantined inbound email %s to %s after %d failed attempts",
+ key, failed_key, MAX_S3_PROCESS_ATTEMPTS,
+ )
+ except Exception:
+ logger.error("Failed to quarantine %s", key, exc_info=True)
except Exception as exc:
logger.error("Error polling inbound emails: %s", exc, exc_info=True)
@@ -130,6 +199,12 @@ async def process_inbound_email(raw_email: bytes, db: AsyncSession) -> None:
if not _authentication_results_ok(msg):
return
+ # Auto-generated mail (OOO replies, etc.) must never be answered — our
+ # help email replying to an auto-responder is a mail loop.
+ if _is_auto_submitted(msg):
+ logger.info("Ignoring auto-submitted inbound mail (Auto-Submitted header)")
+ return
+
# Extract reply token from To header
to_addr = msg.get("To", "")
token = _extract_reply_token(to_addr)
@@ -137,6 +212,12 @@ async def process_inbound_email(raw_email: bytes, db: AsyncSession) -> None:
logger.warning("No reply token found in To address: %s", to_addr)
return
+ if not _reply_rate_ok(token):
+ logger.warning(
+ "Rate limit exceeded for reply token %s... — dropping reply", token[:8]
+ )
+ return
+
# Look up notification by token
result = await db.execute(
select(EmailNotification).where(EmailNotification.reply_token == token)
@@ -259,19 +340,50 @@ def _extract_email_address(from_header: str) -> str | None:
return None
+def _decode_part(part: email.message.Message) -> str:
+ charset = part.get_content_charset() or "utf-8"
+ payload = part.get_payload(decode=True) or b""
+ return payload.decode(charset, errors="replace")
+
+
+def _html_to_text(html_body: str) -> str:
+ """Best-effort text extraction for HTML-only replies.
+
+ Quoted history is dropped structurally (/gmail_quote) because
+ the '>' line-prefix convention below only exists in plain text."""
+ import html as html_mod
+
+ text = re.sub(r"(?is)<(script|style)\b.*?\1>", "", html_body)
+ text = re.sub(r'(?is)]*class="[^"]*gmail_quote[^"]*".*', "", text)
+ text = re.sub(r"(?is)
", "", text)
+ text = re.sub(r"(?i)
||
", "\n", text)
+ text = re.sub(r"(?s)<[^>]+>", "", text)
+ return html_mod.unescape(text)
+
+
def _extract_reply_body(msg: email.message.Message) -> str:
- """Extract the reply body, stripping quoted content and signatures."""
+ """Extract the reply body, stripping quoted content and signatures.
+
+ Prefers text/plain; falls back to tag-stripped text/html so an HTML-only
+ reply (some corporate clients) is not silently dropped."""
body = ""
+ html_body = ""
if msg.is_multipart():
for part in msg.walk():
- if part.get_content_type() == "text/plain":
- charset = part.get_content_charset() or "utf-8"
- body = part.get_payload(decode=True).decode(charset, errors="replace")
+ ctype = part.get_content_type()
+ if ctype == "text/plain":
+ body = _decode_part(part)
break
+ if ctype == "text/html" and not html_body:
+ html_body = _decode_part(part)
+ elif msg.get_content_type() == "text/html":
+ html_body = _decode_part(msg)
else:
- charset = msg.get_content_charset() or "utf-8"
- body = msg.get_payload(decode=True).decode(charset, errors="replace")
+ body = _decode_part(msg)
+
+ if not body.strip() and html_body:
+ body = _html_to_text(html_body)
# Strip quoted content (lines starting with >)
lines = body.split("\n")
diff --git a/tests/unit/test_email_inbound_hardening.py b/tests/unit/test_email_inbound_hardening.py
new file mode 100644
index 0000000..273aef4
--- /dev/null
+++ b/tests/unit/test_email_inbound_hardening.py
@@ -0,0 +1,255 @@
+"""Hardening for inbound email reply processing.
+
+These pin the defects found while investigating the dead prod reply flow
+(2026-08-11): a sender-forged ``Authentication-Results: ... pass`` header
+defeated the SEC-5 anti-spoofing gate, HTML-only replies were silently
+dropped, auto-responders could loop with the help email, the declared
+per-token rate limit was never enforced, and a poison message in the inbound
+bucket was retried forever.
+"""
+
+import email
+
+import pytest
+
+import src.services.email_inbound as inbound
+from src.services.email_inbound import (
+ MAX_REPLIES_PER_TOKEN_PER_HOUR,
+ _authentication_results_ok,
+ _extract_reply_body,
+ _reply_rate_ok,
+ poll_inbound_emails,
+ process_inbound_email,
+)
+
+
+def _msg(raw: str) -> email.message.Message:
+ return email.message_from_string(raw)
+
+
+# --- Authentication-Results: only SES's own (topmost) header is trusted -----
+
+
+def test_forged_pass_header_below_ses_fail_is_rejected():
+ """SES prepends its header on receipt, so a sender-supplied pass sits below
+ it. Merging verdicts across headers let the forged pass win (SEC-5)."""
+ raw = (
+ "Authentication-Results: amazonses.com; spf=fail smtp.mailfrom=evil.com; "
+ "dkim=none; dmarc=fail header.from=scripps.edu\n"
+ "Authentication-Results: amazonses.com; spf=pass; dkim=pass; dmarc=pass\n"
+ "From: pi@scripps.edu\n\nbody"
+ )
+ assert _authentication_results_ok(_msg(raw)) is False
+
+
+def test_verdicts_below_the_topmost_header_are_ignored_entirely():
+ raw = (
+ "Authentication-Results: amazonses.com; spf=pass smtp.mailfrom=scripps.edu; "
+ "dkim=pass; dmarc=pass header.from=scripps.edu\n"
+ "Authentication-Results: evil.example; spf=fail; dkim=fail; dmarc=fail\n"
+ "From: pi@scripps.edu\n\nbody"
+ )
+ assert _authentication_results_ok(_msg(raw)) is True
+
+
+def test_topmost_header_with_foreign_authserv_id_is_rejected():
+ """Everything on our receipt path is stamped by amazonses.com; anything
+ else means the message did not transit SES receiving."""
+ raw = (
+ "Authentication-Results: mx.evil.example; spf=pass; dkim=pass; dmarc=pass\n"
+ "From: pi@scripps.edu\n\nbody"
+ )
+ assert _authentication_results_ok(_msg(raw)) is False
+
+
+# --- HTML-only replies are not silently dropped ------------------------------
+
+
+def test_html_only_reply_body_falls_back_to_stripped_html():
+ raw = (
+ "From: pi@scripps.edu\n"
+ "MIME-Version: 1.0\n"
+ 'Content-Type: multipart/alternative; boundary="xyz"\n'
+ "\n"
+ "--xyz\n"
+ 'Content-Type: text/html; charset="UTF-8"\n'
+ "\n"
+ "4 — excellent, go ahead!
\n"
+ 'quoted proposal text '
+ "1 = Not a good idea
\n"
+ "\n"
+ "--xyz--\n"
+ )
+ body = _extract_reply_body(_msg(raw))
+ assert "4" in body and "excellent" in body
+ assert "Not a good idea" not in body # quoted HTML must not leak through
+
+
+def test_singlepart_html_reply_body_is_extracted():
+ raw = (
+ "From: pi@scripps.edu\n"
+ 'Content-Type: text/html; charset="UTF-8"\n'
+ "\n"
+ "2 & please focus on assay development
\n"
+ )
+ body = _extract_reply_body(_msg(raw))
+ assert "2 & please focus on assay development" in body
+
+
+def test_plain_text_part_still_wins_over_html():
+ raw = (
+ "From: pi@scripps.edu\n"
+ "MIME-Version: 1.0\n"
+ 'Content-Type: multipart/alternative; boundary="qq"\n'
+ "\n"
+ "--qq\n"
+ 'Content-Type: text/plain; charset="UTF-8"\n'
+ "\n"
+ "3 sounds great\n"
+ "\n"
+ "--qq\n"
+ 'Content-Type: text/html; charset="UTF-8"\n'
+ "\n"
+ "3 sounds great
\n"
+ "\n"
+ "--qq--\n"
+ )
+ assert _extract_reply_body(_msg(raw)) == "3 sounds great"
+
+
+# --- Auto-submitted mail is dropped before any processing --------------------
+
+
+_SES_PASS = "Authentication-Results: amazonses.com; spf=pass; dkim=pass; dmarc=pass\n"
+
+
+async def test_auto_submitted_reply_is_ignored_before_touching_the_db():
+ """RFC 3834: an OOO auto-reply answering our help email must not trigger
+ another help email (mail loop). db=None proves the early return."""
+ raw = (
+ _SES_PASS
+ + "Auto-Submitted: auto-replied\n"
+ "From: pi@scripps.edu\n"
+ "To: review+sometoken@reply.copi.science\n"
+ "\n"
+ "I am out of the office.\n"
+ ).encode()
+ await process_inbound_email(raw, db=None) # must not raise
+
+
+async def test_auto_submitted_no_is_not_treated_as_an_auto_reply():
+ """``Auto-Submitted: no`` explicitly marks human-generated mail; it must
+ proceed into normal processing (here: to the token lookup, which needs a
+ db — the AttributeError on db=None is the evidence it got past the gate)."""
+ raw = (
+ _SES_PASS
+ + "Auto-Submitted: no\n"
+ "From: pi@scripps.edu\n"
+ "To: review+sometoken@reply.copi.science\n"
+ "\n"
+ "3 great idea\n"
+ ).encode()
+ with pytest.raises(AttributeError):
+ await process_inbound_email(raw, db=None)
+
+
+# --- The declared per-token rate limit is enforced ---------------------------
+
+
+def test_reply_rate_limit_blocks_the_11th_reply_in_an_hour(monkeypatch):
+ monkeypatch.setattr(inbound, "_RECENT_REPLY_TIMES", {})
+ token = "tok-" + "x" * 60
+ base = 1_000_000.0
+ for i in range(MAX_REPLIES_PER_TOKEN_PER_HOUR):
+ assert _reply_rate_ok(token, now=base + i) is True
+ assert _reply_rate_ok(token, now=base + 60) is False
+
+
+def test_reply_rate_limit_window_slides(monkeypatch):
+ monkeypatch.setattr(inbound, "_RECENT_REPLY_TIMES", {})
+ token = "tok-" + "y" * 60
+ base = 2_000_000.0
+ for i in range(MAX_REPLIES_PER_TOKEN_PER_HOUR):
+ assert _reply_rate_ok(token, now=base + i) is True
+ # An hour later the old entries have aged out.
+ assert _reply_rate_ok(token, now=base + 3601) is True
+
+
+# --- Poison messages are quarantined, not retried forever --------------------
+
+
+class _FakeS3:
+ """Just enough of the S3 client for poll_inbound_emails."""
+
+ def __init__(self, keys):
+ self.objects = {k: b"raw email bytes" for k in keys}
+ self.copied: list[tuple[str, str]] = []
+ self.deleted: list[str] = []
+
+ def list_objects_v2(self, Bucket, Prefix, MaxKeys):
+ return {
+ "Contents": [{"Key": k} for k in sorted(self.objects)],
+ "KeyCount": len(self.objects),
+ }
+
+ def get_object(self, Bucket, Key):
+ import io
+
+ return {"Body": io.BytesIO(self.objects[Key])}
+
+ def copy_object(self, Bucket, CopySource, Key):
+ self.copied.append((CopySource["Key"], Key))
+ self.objects[Key] = self.objects[CopySource["Key"]]
+
+ def delete_object(self, Bucket, Key):
+ self.deleted.append(Key)
+ self.objects.pop(Key, None)
+
+
+class _NullSessionFactory:
+ def __call__(self):
+ return self
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *exc):
+ return False
+
+ async def commit(self):
+ pass
+
+
+async def test_poison_email_is_quarantined_after_repeated_failures(monkeypatch):
+ fake = _FakeS3(["inbound/poison"])
+ monkeypatch.setattr("boto3.client", lambda *a, **k: fake)
+ monkeypatch.setattr(inbound, "_S3_FAILURE_COUNTS", {})
+
+ async def _boom(raw, db):
+ raise RuntimeError("unparseable in a way that always raises")
+
+ monkeypatch.setattr(inbound, "process_inbound_email", _boom)
+
+ for _ in range(inbound.MAX_S3_PROCESS_ATTEMPTS):
+ assert await poll_inbound_emails(_NullSessionFactory()) == 0
+
+ assert fake.copied == [("inbound/poison", "failed/poison")]
+ assert fake.deleted == ["inbound/poison"]
+ # Quarantined: the next poll sees only failed/ (outside the prefix filter
+ # in real S3; the fake returns everything, so assert the key is gone).
+ assert "inbound/poison" not in fake.objects
+
+
+async def test_a_transient_failure_is_retried_not_quarantined(monkeypatch):
+ fake = _FakeS3(["inbound/flaky"])
+ monkeypatch.setattr("boto3.client", lambda *a, **k: fake)
+ monkeypatch.setattr(inbound, "_S3_FAILURE_COUNTS", {})
+
+ async def _boom(raw, db):
+ raise RuntimeError("db briefly down")
+
+ monkeypatch.setattr(inbound, "process_inbound_email", _boom)
+ await poll_inbound_emails(_NullSessionFactory())
+
+ assert fake.copied == []
+ assert "inbound/flaky" in fake.objects # still there for the next poll
From 1284042d7216824e3f2ae520a5f92777de19fe4e Mon Sep 17 00:00:00 2001
From: alan
Date: Tue, 11 Aug 2026 08:58:55 -0500
Subject: [PATCH 2/6] fix(email): only solicit replies when the inbound
pipeline is enabled
Prod sent 129 review emails telling PIs to "reply to this email to rate
it" while ENABLE_INBOUND_EMAIL was off and the reply infrastructure (MX
record, S3 bucket, receipt rule) did not exist - every PI who replied got
silence plus an eventual bounce, which is the reported failure.
Gate the reply-soliciting copy and the Reply-To header on
settings.enable_inbound_email in the proposal-review reminder, the
new-proposal alert, and the welcome email. When the flag is off, all
three direct PIs to the web dashboard only, so outbound email can be
safely re-enabled before (or without) provisioning inbound.
Co-Authored-By: Claude Fable 5
---
src/services/email.py | 53 +++++--
src/services/email_notifications.py | 106 +++++++++----
tests/unit/test_email_reply_solicitation.py | 161 ++++++++++++++++++++
3 files changed, 279 insertions(+), 41 deletions(-)
create mode 100644 tests/unit/test_email_reply_solicitation.py
diff --git a/src/services/email.py b/src/services/email.py
index f446120..871fc8b 100644
--- a/src/services/email.py
+++ b/src/services/email.py
@@ -198,6 +198,43 @@ def build_welcome_email(to_email: str, name: str | None = None, user_id: str | N
greeting_name = (name or "").strip().split(" ")[0] if name else ""
greeting = f"Hi {greeting_name}," if greeting_name else "Hi there,"
+
+ # Only describe the reply-by-email review flow when the inbound pipeline
+ # is actually enabled; otherwise point at the web dashboard alone.
+ reply_enabled = settings.enable_inbound_email
+ if reply_enabled:
+ review_how_text = (
+ "HOW PROPOSAL REVIEW WORKS\n"
+ "When your agent and another lab's agent develop a promising idea, we email\n"
+ "you a short proposal. You can:\n"
+ " - Reply with a rating from 1 to 4:\n"
+ " 1 = Not a good idea 2 = Good idea\n"
+ " 3 = Great idea 4 = Excellent idea\n"
+ ' - Reply with instructions (e.g. "focus on the mitochondrial angle") and\n'
+ " your agent will re-engage to refine the idea.\n"
+ " - Or review it on the web dashboard.\n"
+ "Note: while you have unreviewed proposals, your agent pauses new\n"
+ "conversations — reviewing promptly keeps it active."
+ )
+ review_how_html = (
+ "Rate it by replying with a number from 1 to 4.\n"
+ " Give instructions to refine it, and your agent re-engages.\n"
+ " Review it on the web dashboard."
+ )
+ else:
+ review_how_text = (
+ "HOW PROPOSAL REVIEW WORKS\n"
+ "When your agent and another lab's agent develop a promising idea, we email\n"
+ "you a short proposal. Open your dashboard to rate it from 1 to 4\n"
+ "(1 = Not a good idea, 2 = Good idea, 3 = Great idea, 4 = Excellent idea)\n"
+ "or to give your agent instructions to refine the idea.\n"
+ "Note: while you have unreviewed proposals, your agent pauses new\n"
+ "conversations — reviewing promptly keeps it active."
+ )
+ review_how_html = (
+ "Rate it from 1 to 4 on your dashboard.\n"
+ " Give instructions to refine it, and your agent re-engages."
+ )
# HTML-escaped greeting for the HTML body (the name is the ORCID display
# name, i.e. user-controlled) (SEC-13).
greeting_html = f"Hi {esc(greeting_name)}," if greeting_name else "Hi there,"
@@ -226,17 +263,7 @@ def build_welcome_email(to_email: str, name: str | None = None, user_id: str | N
- My Agent ({agent_url}) — request your agent and manage it.
- Settings ({settings_url}) — choose which emails you receive and how often.
-HOW PROPOSAL REVIEW WORKS
-When your agent and another lab's agent develop a promising idea, we email
-you a short proposal. You can:
- - Reply with a rating from 1 to 4:
- 1 = Not a good idea 2 = Good idea
- 3 = Great idea 4 = Excellent idea
- - Reply with instructions (e.g. "focus on the mitochondrial angle") and
- your agent will re-engage to refine the idea.
- - Or review it on the web dashboard.
-Note: while you have unreviewed proposals, your agent pauses new
-conversations — reviewing promptly keeps it active.
+{review_how_text}
Welcome aboard,
The CoPI team — Scripps Research
@@ -321,9 +348,7 @@ def build_welcome_email(to_email: str, name: str | None = None, user_id: str | N
you a short proposal. You can:
- - Rate it by replying with a number from 1 to 4.
- - Give instructions to refine it, and your agent re-engages.
- - Review it on the web dashboard.
+ {review_how_html}
1 = Not a good idea • 2 = Good idea • 3 = Great idea • 4 = Excellent idea
diff --git a/src/services/email_notifications.py b/src/services/email_notifications.py
index 9e01e82..bdf26c6 100644
--- a/src/services/email_notifications.py
+++ b/src/services/email_notifications.py
@@ -331,8 +331,13 @@ async def send_proposal_notification(
db.add(notification)
await db.flush()
- # Build email
- reply_to = f"review+{reply_token}@{settings.ses_reply_domain}"
+ # Build email. Soliciting a reply is only honest when the inbound pipeline
+ # is actually on — otherwise PIs answer a dead reply domain and get
+ # silence (this is exactly what happened on prod through 2026-08).
+ reply_enabled = settings.enable_inbound_email
+ reply_to = (
+ f"review+{reply_token}@{settings.ses_reply_domain}" if reply_enabled else None
+ )
dashboard_url = f"{settings.base_url}/agent/{agent.agent_id}/dashboard"
unsubscribe_token = _generate_unsubscribe_token(str(user.id))
unsubscribe_url = f"{settings.base_url}/settings/unsubscribe/{unsubscribe_token}"
@@ -373,25 +378,58 @@ async def send_proposal_notification(
f"Review all proposals.
"
)
+ if reply_enabled:
+ review_options_text = (
+ f"To review this proposal, you can:\n\n"
+ f"1. Reply to this email with a rating (1-4) and any comments:\n"
+ f" 1 = Not a good idea (not interesting, or multiple major weaknesses)\n"
+ f" 2 = Good idea (medium interest, or one major weakness)\n"
+ f" 3 = Great idea (high interest, minor weaknesses only)\n"
+ f" 4 = Excellent idea (high interest, no notable weaknesses)\n\n"
+ f"2. Reply with instructions for your agent (e.g., \"focus on the\n"
+ f' mitochondrial angle instead") and it will re-engage to refine\n'
+ f" the proposal.\n\n"
+ f"3. Review on the web: {dashboard_url}\n"
+ )
+ else:
+ review_options_text = (
+ f"To review this proposal, rate it on your dashboard: {dashboard_url}\n"
+ f" 1 = Not a good idea (not interesting, or multiple major weaknesses)\n"
+ f" 2 = Good idea (medium interest, or one major weakness)\n"
+ f" 3 = Great idea (high interest, minor weaknesses only)\n"
+ f" 4 = Excellent idea (high interest, no notable weaknesses)\n"
+ )
+
text_body = (
f"{agent.bot_name} and {other_bot_name} developed a collaboration proposal in #{channel}:\n\n"
f"---\n{summary}\n---\n\n"
- f"To review this proposal, you can:\n\n"
- f"1. Reply to this email with a rating (1-4) and any comments:\n"
- f" 1 = Not a good idea (not interesting, or multiple major weaknesses)\n"
- f" 2 = Good idea (medium interest, or one major weakness)\n"
- f" 3 = Great idea (high interest, minor weaknesses only)\n"
- f" 4 = Excellent idea (high interest, no notable weaknesses)\n\n"
- f"2. Reply with instructions for your agent (e.g., \"focus on the\n"
- f' mitochondrial angle instead") and it will re-engage to refine\n'
- f" the proposal.\n\n"
- f"3. Review on the web: {dashboard_url}\n"
+ f"{review_options_text}"
f"{backlog_text}\n"
f"---\n"
f"Unsubscribe: {unsubscribe_url}\n"
f"Manage preferences: {settings_url}\n"
)
+ rating_legend_html = (
+ '\n'
+ " 1 = Not a good idea • 2 = Good idea • 3 = Great idea • 4 = Excellent idea\n"
+ "
"
+ )
+ if reply_enabled:
+ review_options_html = (
+ 'Reply to this email to review:
\n'
+ ' \n'
+ " - Rate it with a number 1-4 and any comments
\n"
+ " - Give instructions to refine the proposal
\n"
+ "
\n"
+ f" {rating_legend_html}"
+ )
+ else:
+ review_options_html = (
+ 'Rate it on your dashboard:
\n'
+ f" {rating_legend_html}"
+ )
+
html_body = email_shell_open() + f"""
New collaboration proposal
@@ -402,14 +440,7 @@ async def send_proposal_notification(
{summary_html}
- Reply to this email to review:
-
- - Rate it with a number 1-4 and any comments
- - Give instructions to refine the proposal
-
-
- 1 = Not a good idea • 2 = Good idea • 3 = Great idea • 4 = Excellent idea
-
+ {review_options_html}