Skip to content

feat(gateway): add EMAIL_SUPPRESS_OUTBOUND kill switch for draft-only mailboxes - #5717

Closed
eve-coda wants to merge 1 commit into
NousResearch:mainfrom
eve-coda:feat/email-suppress-outbound
Closed

feat(gateway): add EMAIL_SUPPRESS_OUTBOUND kill switch for draft-only mailboxes#5717
eve-coda wants to merge 1 commit into
NousResearch:mainfrom
eve-coda:feat/email-suppress-outbound

Conversation

@eve-coda

@eve-coda eve-coda commented Apr 7, 2026

Copy link
Copy Markdown

What does this PR do?

Adds an EMAIL_SUPPRESS_OUTBOUND env var to the email gateway adapter that, when truthy, unconditionally drops all outbound SMTP from send(), send_image(), and send_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 via send_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.py and gateway/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

  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix (defense against prompt injection from inbound mail)

Changes Made

  • gateway/platforms/email.py:

    • Add module-level _parse_bool_env(name) helper with strict validation. Accepts true/false, 1/0, yes/no, on/off (case-insensitive, whitespace-tolerant), or unset. Anything else raises ValueError at parse time. No silent default — a typo in a safety flag would mean leaked email.
    • Add self._suppress_outbound to EmailAdapter.__init__, parsed from EMAIL_SUPPRESS_OUTBOUND. Logs a WARNING at adapter init if true so the operating mode is visible in default log output.
    • Add public-method short-circuit in send() and send_document(): when the flag is set, log INFO and return SendResult(success=True, message_id=None) without contacting SMTP. send_image() is covered transitively because it routes through send().
    • Add backstops in _send_email() and _send_email_with_attachment() that log ERROR and raise RuntimeError if 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 — add TestSuppressOutbound class with 11 test cases covering: default-off, explicit-false, all three public send paths suppressed, all recognized truthy/falsy spellings (via subTest), invalid-value rejection at init, and both private-method backstops.

  • .env.example — add commented EMAIL_SUPPRESS_OUTBOUND=false block in the Email section with rationale.

  • website/docs/reference/environment-variables.md — add row to the EMAIL_* 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

  1. Unit tests: pytest tests/gateway/test_email.py -q → 79 passed, 24 subtests passed (the 11 new tests in TestSuppressOutbound cover the full surface).
  2. Manual verification (suppression on):
    • Set EMAIL_SUPPRESS_OUTBOUND=true in ~/.hermes/.env
    • Restart the gateway. Confirm the WARNING log line appears at startup: "EMAIL_SUPPRESS_OUTBOUND=true — adapter will NOT send any outbound mail via SMTP..."
    • Send any inbound email to the mailbox.
    • Verify the agent processes the message normally and that nothing lands in the Sent folder. Adapter logs should show: "[Email] Suppressed outbound to (EMAIL_SUPPRESS_OUTBOUND=true)"
  3. Manual verification (suppression off, regression check):
    • Set EMAIL_SUPPRESS_OUTBOUND=false (or unset).
    • Restart the gateway and verify the WARNING does not appear.
    • Send an inbound email. Verify the agent's reply is delivered as normal.
  4. Invalid value:
    • Set EMAIL_SUPPRESS_OUTBOUND=maybe and start the gateway.
    • Verify the email adapter fails to start with a ValueError listing the accepted values.

Tested on Arch Linux with Python 3.11 against the project's bundled venv.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (feat(gateway): ...)
  • I searched for existing PRs to make sure this isn't a duplicate (related but distinct: fix(cron): suppress delivery only when response is exactly [SILENT] #4308 clarifies the cron [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.)
  • My PR contains only changes related to this feature
  • I've run pytest tests/gateway/test_email.py -q and all 79 tests pass. The full pytest tests/ run has pre-existing failures in test_hermes_logging.py, test_delegate.py, test_skill_manager_tool.py, and test_matrix.py — verified to exist on clean origin/main without this PR's changes; none touch gateway/platforms/email.py.
  • I've added tests for my changes (11 new cases)
  • I've tested on my platform: Arch Linux, Python 3.11

Documentation & Housekeeping

  • Documentation updated: website/docs/reference/environment-variables.md, website/docs/user-guide/messaging/email.md, .env.example
  • cli-config.yaml.example — N/A (env var only, no yaml key)
  • CONTRIBUTING.md / AGENTS.md — N/A
  • Cross-platform impact considered — N/A (pure Python, no platform-specific calls; env var parsing uses stdlib only)
  • Tool descriptions/schemas — N/A

… 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.
@jonnyace

jonnyace commented Apr 7, 2026

Copy link
Copy Markdown

following this

@jonnyace

jonnyace commented Apr 17, 2026

Copy link
Copy Markdown

@teknium1 double tapping this --my agent is sending this emails in response to anyone that emails in:
image

This patch works for me but needs to be reapplied each update. Is this an AI slop fix or worth merging?

@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/gateway Gateway runner, session dispatch, delivery platform/email Email (IMAP/SMTP) adapter labels Apr 30, 2026
@teknium1

Copy link
Copy Markdown
Contributor

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:

  • The PR's public mechanism is the new non-secret behavioral flag EMAIL_SUPPRESS_OUTBOUND in .env; repository policy requires behavioral flags to live in config.yaml rather than .env (AGENTS.md:102-107).
  • The submitted patch is also based on the former inline adapter path. Current email delivery lives in plugins/platforms/email/adapter.py after 560010547, with additional SMTP paths including multi-attachment delivery and _standalone_send (plugins/platforms/email/adapter.py:893-1229).

A focused follow-up using a platforms.email config setting, wired through all current email egress paths and the existing setup/config UX, would be the supported direction.


Closed as not-planned per standing maintainer policy (env-var-for-config). This is a design-direction decision, not a code-quality judgment — see the Contribution Rubric in AGENTS.md for what the project is looking for. If you believe this policy was misapplied to your change, comment here and a maintainer will take a look.

@teknium1 teknium1 closed this Jul 12, 2026
@teknium1 teknium1 added the sweeper:not-planned Sweeper: closed per standing maintainer policy (design direction) label Jul 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P3 Low — cosmetic, nice to have platform/email Email (IMAP/SMTP) adapter sweeper:not-planned Sweeper: closed per standing maintainer policy (design direction) type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants