fix(channels): refuse email sends into an unknown thread instead of mailing the Message-ID - #1847
Merged
andreapn merged 1 commit intoSep 12, 2026
Conversation
…ailing the Message-ID EmailChannel._resolve_target fell back to treating reply_to as a mailbox whenever the thread it named was not in the in-memory routing cache. But reply_to is the inbound thread key -- an RFC 5322 Message-ID, which has a mailbox's local@domain shape and a domain chosen by whoever sent the original mail. After a restart or LRU eviction the agent's reply was silently mailed to that address. Drop the fallback (and the is_email_address helper that existed for it): an unknown thread with no metadata["to"]/["recipient"] now raises a descriptive ValueError and logs email.send_unknown_thread, so send_file reports a non-retryable failure and channel replies fail loudly. Scheduler and heartbeat delivery used to rely on the fallback for operator-configured addresses. They now pass metadata["to"] -- but only when channel_id is a configured recipient. The same field is the thread key when a job was created from an email conversation (origin mode, or a rendezvous snapshot), and naming that as the recipient would mail the Message-ID even on a cache hit. DeliveryChain derives this from the job (mode=channel without a snapshot; failure destinations); HeartbeatService from delivery.mode, with delivery overrides now saying which mode they represent. Closes use-agent-os#1570 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfPC1g1hsFx5Tw7LefvgDN
This was referenced Sep 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Linked issue
Closes #1570
Summary
EmailChannel._resolve_targetfell back tois_email_address(reply_to)whenever the thread named byreply_towas not in the in-memory_threadscache. Butreply_tois the inbound thread key — an RFC 5322 Message-ID, which has a mailbox'slocal@domainshape and a domain chosen by whoever sent the original mail. After a restart or LRU eviction the agent's reply was silently mailed to that address.Fix
channels/email.py— the fallback (and theis_email_addresshelper that existed for it) is gone. An unknown thread with nometadata["to"]/["recipient"]raisesemail.send has no recipient for unknown thread '<key>': ...and logsemail.send_unknown_thread(two producers swallow send exceptions, so the log line is what makes the refusal visible).send_filereports a non-retryable failure. A message with no target at all gets its own message.metadata["to"]— but only whenchannel_idis a configured recipient. The same field is the thread key when a job was created from an email conversation (mode=origin, or anoriginating_reply_targetrendezvous snapshot); naming that as the recipient would mail the Message-ID even on a cache hit, and re-open the exact hole on a miss.scheduler/delivery.py:configured_recipient = job.delivery.originating_reply_target is None and mode == CHANNEL; failure destinations are always configured. This proxy holds because the cron tool refusesmode=channelfrom chat callers, so a snapshot-less channel job can only come from an operator (CLI/RPC/Web).scheduler/heartbeat_service.py: usesdelivery.mode == CHANNEL. In thetarget: lastbranch an override is promoted to channel mode only when it says"mode": "channel"and supplies achannel_id(a channel-mode override without an id still targets the inferred conversation).scheduler/handlers.py/heartbeat_loop.py: overrides now say which mode they represent (_delivery_override_from_fieldsincludesmode; the loop marks a configuredtoas channel mode; snapshot overrides carry nothing and stay thread replies).docs/channels.md: "Threads and Sessions" describes the refusal and which deliveries do not depend on the routing table.Compared with the other open PRs on this issue
#1644 removes the fallback but leaves scheduler/heartbeat sending the bare address, which turns every configured cron/heartbeat email delivery into a hard failure. #1827 additionally sets
metadata["to"] = channel_idunconditionally indelivery.py, which mails the Message-ID for jobs created from an email conversation — even when the thread is still cached — and does not touch the heartbeat producer at all. This PR threads the provenance through so both classes keep working and no path can populatetofrom a thread key.Tests
test_email_channel.py: the issue's Message-ID key, an attacker-domain key and a bare mailbox are all refused with nothing sent; a reply works before_threads.clear()and is refused after;send_fileinto an evicted thread reportsFAILED(not retryable); the empty-target message; scheduler-stylemetadata["to"]still resolves; precedence test updated (to>recipient> cache;reply_tois never a mailbox). The test that encoded the vulnerable contract is replaced.test_multi_account_delivery.py: channel-mode email →{"to": addr}; origin-mode email →{}; snapshot rendezvous →{}with the snapshot key asreply_to; failure destination →{"to": addr}; telegram channel-mode →{}(untouched).test_heartbeat_email_delivery.py(new):_send_deliveryfor channel vs origin mode;run_once(target="last")end-to-end with no override / configured override / snapshot override / channel-mode override without an id; explicittarget="email"; andHeartbeatLoop._tickoverride construction with and without a configuredto.test_system_event_pinned_delivery.py: override dicts now carrymode.Follow-up (out of scope, same shape)
The
messagetool writesmetadata["recipient"] = targetunconditionally andrecipientoutranks the thread cache, somessage(channel="email", target="<Message-ID>")would still mail the key. That is an explicit-address contract rather than this bug; worth its own issue.Quality gate
uv run ruff check src tests,uv run mypy src/agentos, fulluv run pytest -q— all green (onetest_pilot_benchmarklatency assertion flaked under load with five suites in parallel; passes alone).No
CHANGELOG.mdentry on purpose: this PR is one of five opened together (#1570 #1571 #1573 #1575 #1577), each on disjoint files, and the changelog is the one file they would all collide on. All 120 merge orderings were verified conflict-free againstupstream/main, and the fully merged tree passes the complete gate. Happy to add the bullet in a follow-updocs(changelog)once the batch lands.🤖 Generated with Claude Code
https://claude.ai/code/session_01EfPC1g1hsFx5Tw7LefvgDN