feat(gateway): add EMAIL_SUPPRESS_OUTBOUND kill switch for draft-only mailboxes - #5717
feat(gateway): add EMAIL_SUPPRESS_OUTBOUND kill switch for draft-only mailboxes#5717eve-coda wants to merge 1 commit into
Conversation
… mailboxes The email gateway adapter currently has no way to operate as a read-only / draft-only mailbox. Every call to send(), send_image(), or send_document() will SMTP an outbound message, regardless of operator intent. This makes the adapter unusable for human-in-the-loop workflows where every reply must be approved on a separate channel before going out, and it leaves operators with no defense against an LLM that generates an unwanted reply (whether due to misconfiguration or prompt injection from inbound email content). This change adds an EMAIL_SUPPRESS_OUTBOUND env var that, when truthy, unconditionally drops all outbound SMTP from the adapter. Inbound IMAP polling is unaffected — the agent still sees inbound mail and can act on it via other tools (e.g. cross-posting to a chat platform via send_message). The flag is enforced at the adapter, not via prompts, so it cannot be bypassed by an LLM forgetting an instruction. The guard is implemented in two layers for defense-in-depth: 1. Public-method short-circuit in send() and send_document(). These are the fast paths and what callers normally hit. send_image() is covered transitively because it routes through send(). 2. Backstop in _send_email() and _send_email_with_attachment() that raises RuntimeError. These should never fire under normal operation; if they do, that means a future code path was added without the public-method guard, and the backstop prevents the leak while logging an ERROR-level message so the bug is visible. Env var parsing is strict: accepted values are true/false, 1/0, yes/no, on/off (case-insensitive, whitespace-tolerant), or unset. Anything else raises ValueError at adapter init time. This is intentional — silent fallback to false on a typo would mean leaked email, which is the worst possible failure mode for a safety flag. A WARNING-level log line at adapter init makes the operating mode visible in default log configurations. INFO-level logs on each suppression call let operators verify the flag is active. Use cases this enables: - Human-in-the-loop email approval flows: agent reads inbound mail, posts a draft to a chat platform via send_message, human approves, the approved reply is sent through a separate path (e.g. a Gmail API skill) that does not go through this adapter. - Compliance-restricted environments where automated outbound mail is disallowed but inbound monitoring is desired. - Defense against prompt injection: an attacker who hijacks the agent via a crafted inbound message cannot make the adapter SMTP anything outbound. - Staging / testing environments for email-using agents. Tests cover: default-off behavior, explicit-false behavior, all three public send paths suppressed when on, all recognized truthy/falsy spellings, invalid-value rejection at init, and both private-method backstops. 11 new tests in TestSuppressOutbound, all passing. tests/gateway/test_email.py: 79 passing (up from 68), 24 subtests passing.
|
following this |
|
@teknium1 double tapping this --my agent is sending this emails in response to anyone that emails in: This patch works for me but needs to be reapplied each update. Is this an AI slop fix or worth merging? |
|
Thanks for the concrete draft-only workflow and the defense-in-depth tests. The safety goal is understandable, and the discussion from @jonnyace confirms a real operator need. This automated hermes-sweeper review is closing this as a configuration-direction mismatch:
A focused follow-up using a Closed as not-planned per standing maintainer policy ( |

What does this PR do?
Adds an
EMAIL_SUPPRESS_OUTBOUNDenv var to the email gateway adapter that, when truthy, unconditionally drops all outbound SMTP fromsend(),send_image(), andsend_document(). Inbound IMAP polling is unaffected — the agent still receives inbound mail and can act on it via other tools (e.g. cross-posting drafts to a chat platform viasend_message). The flag is enforced at the adapter, not via prompts, so it cannot be bypassed by an LLM forgetting an instruction or by prompt injection from inbound email content.This unlocks human-in-the-loop email workflows where every outbound reply must be approved on a separate channel before going out, and it provides a hard safety guarantee that no other mechanism in the codebase currently offers for the email adapter.
Why a kill switch and not a prompt instruction?
A prompt-level instruction ("end every reply with
[SILENT]" etc.) relies on the LLM emitting exactly the right token, which is fragile. Any deviation —Done. [SILENT],[SILENT] (handled in chat), a forgotten marker — leaks an email. A kill switch enforced at the adapter is a hard guarantee that does not depend on prompt discipline or model reliability.The marker-based approach is still the right primitive for per-message agent discretion (and is used by
cron/scheduler.pyandgateway/builtin_hooks/boot_md.py). This PR addresses the orthogonal case of operator-level configuration: "this mailbox NEVER auto-replies, full stop."Related Issue
(no existing issue — search of open/closed issues + PRs returned no prior art for this on the email adapter; happy to file one if preferred)
Type of Change
Changes Made
gateway/platforms/email.py:_parse_bool_env(name)helper with strict validation. Acceptstrue/false,1/0,yes/no,on/off(case-insensitive, whitespace-tolerant), or unset. Anything else raisesValueErrorat parse time. No silent default — a typo in a safety flag would mean leaked email.self._suppress_outboundtoEmailAdapter.__init__, parsed fromEMAIL_SUPPRESS_OUTBOUND. Logs aWARNINGat adapter init if true so the operating mode is visible in default log output.send()andsend_document(): when the flag is set, logINFOand returnSendResult(success=True, message_id=None)without contacting SMTP.send_image()is covered transitively because it routes throughsend()._send_email()and_send_email_with_attachment()that logERRORand raiseRuntimeErrorif reached with the flag set. Should never fire under normal use; protects against future code paths bypassing the public-method guard.tests/gateway/test_email.py— addTestSuppressOutboundclass with 11 test cases covering: default-off, explicit-false, all three public send paths suppressed, all recognized truthy/falsy spellings (viasubTest), invalid-value rejection at init, and both private-method backstops..env.example— add commentedEMAIL_SUPPRESS_OUTBOUND=falseblock in the Email section with rationale.website/docs/reference/environment-variables.md— add row to theEMAIL_*table.website/docs/user-guide/messaging/email.md— add row to the env vars reference table and a new "Draft-only / approval-required mode" section explaining the use case, semantics, and a typical HITL architecture.How to Test
pytest tests/gateway/test_email.py -q→ 79 passed, 24 subtests passed (the 11 new tests inTestSuppressOutboundcover the full surface).EMAIL_SUPPRESS_OUTBOUND=truein~/.hermes/.envEMAIL_SUPPRESS_OUTBOUND=false(or unset).EMAIL_SUPPRESS_OUTBOUND=maybeand start the gateway.ValueErrorlisting the accepted values.Tested on Arch Linux with Python 3.11 against the project's bundled venv.
Checklist
Code
feat(gateway): ...)[SILENT]sentinel semantics; feat(skills): add optional mail auto-draft skill #3471 adds an optional skill for Himalaya-based draft workflows. Both operate at different layers — this PR is the underlying adapter primitive.)pytest tests/gateway/test_email.py -qand all 79 tests pass. The fullpytest tests/run has pre-existing failures intest_hermes_logging.py,test_delegate.py,test_skill_manager_tool.py, andtest_matrix.py— verified to exist on cleanorigin/mainwithout this PR's changes; none touchgateway/platforms/email.py.Documentation & Housekeeping
website/docs/reference/environment-variables.md,website/docs/user-guide/messaging/email.md,.env.examplecli-config.yaml.example— N/A (env var only, no yaml key)CONTRIBUTING.md/AGENTS.md— N/A