Skip to content

fix(channels): stop email replies from falling back to reply_to as an… - #1644

Closed
Preciousuche wants to merge 1 commit into
use-agent-os:mainfrom
Preciousuche:fix/email-channel-thread-cache-miss-misdelivery-1570
Closed

Preciousuche wants to merge 1 commit into
use-agent-os:mainfrom
Preciousuche:fix/email-channel-thread-cache-miss-misdelivery-1570

Conversation

@Preciousuche

Copy link
Copy Markdown
Contributor

Summary
EmailChannel._resolve_target fell back to treating reply_to as a raw mailbox (via is_email_address) whenever the thread it named was unknown
reply_to always names a thread — the inbound Message-ID — never a mailbox by itself, but a Message-ID has the same local@domain shape as a real address, and its domain is chosen by whoever sent the original mail
self._threads is an in-memory, LRU-bounded cache that is empty after any process restart, so once a thread aged out, the fallback silently redirected the reply to whatever domain the original sender's Message-ID happened to carry — a mailbox they control
A smarter is_email_address cannot fix this: a Message-ID is genuinely indistinguishable from an address by shape
Removed the fallback entirely; _resolve_target now raises when the thread is unknown and no explicit metadata["to"]/["recipient"] is present, converting a silent misdelivery into a loud, actionable failure
Checked every real producer that relies on reply_to alone for email (scheduler/heartbeat delivery, artifact_delivery.py's send_file calls) — they all pass an existing channel_id, i.e. a thread the cache is expected to still know about, and none has a legitimate case for reply_to ever meaning a fresh, unseen mailbox. The message tool already supplies metadata["recipient"] unconditionally, so it was never relying on the fallback either
Two existing tests encoded the old (vulnerable) contract as intended behavior — one literally docstringed "scheduler and heartbeat delivery pass the address as reply_to alone" — and needed updating to the corrected one
Fixes #1570

Test plan
Added a direct security regression test reproducing the issue's exact steps: receive an inbound email whose Message-ID carries an attacker-controlled domain, evict the thread cache (simulating LRU eviction or a restart), attempt the reply, confirm it's refused rather than misdelivered
Added a general "unknown reply_to that looks like an address" test
Updated the two existing tests that had encoded the vulnerable fallback as expected behavior, to the corrected precedence chain (to > recipient > known thread, with a clear error when none apply)
uv run pytest tests/test_channels/test_email_channel.py -q — 85 passed
uv run pytest tests/test_channels tests/test_gateway -q — 1948 passed, 1 skipped (full suite, unrelated to this change)
uv run ruff check / uv run mypy clean on changed files

… address

EmailChannel._resolve_target fell back to treating reply_to as a raw
mailbox (via is_email_address) whenever the thread it named was
unknown. reply_to always names a thread -- the inbound Message-ID --
never a mailbox by itself, but a Message-ID has the same local@domain
shape as a real address, and its domain is chosen by whoever sent the
original mail. self._threads is an in-memory, LRU-bounded cache that
is empty after any process restart, so once a thread aged out, the
fallback silently redirected the reply to whatever domain the original
sender's Message-ID happened to carry -- a mailbox they control.

A smarter is_email_address cannot fix this: a Message-ID is genuinely
indistinguishable from an address by shape. Removed the fallback
entirely; _resolve_target now raises when the thread is unknown and no
explicit metadata["to"]/["recipient"] is present, converting a silent
misdelivery into a loud, actionable failure.

Checked every real producer that relies on reply_to alone for email
(scheduler/heartbeat delivery, artifact_delivery.py's send_file calls)
-- they all pass an existing channel_id, i.e. a thread the cache is
expected to still know about, and none has a legitimate case for
reply_to ever meaning a fresh, unseen mailbox. The message tool
already supplies metadata["recipient"] unconditionally, so it was
never relying on the fallback either. Two existing tests encoded the
old (vulnerable) contract as intended behavior and needed updating to
the corrected one.

Fixes use-agent-os#1570
@andreapn

Copy link
Copy Markdown
Contributor

Thanks for the PR. #1570 was fixed by #1847, which also updates the scheduler/heartbeat producers. This branch removes the reply_to fallback without giving cron/heartbeat a way to pass metadata["to"], so configured email deliveries (cron channel_id: alerts@…, heartbeat to:) would start raising no recipient. Closing in favour of #1847.

@andreapn andreapn closed this Sep 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: EmailChannel._resolve_target sends replies to Message-ID instead of sender address when thread cache misses

3 participants