agent-email-guard 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. agent_email_guard-0.1.0/.gitignore +7 -0
  2. agent_email_guard-0.1.0/LICENSE +21 -0
  3. agent_email_guard-0.1.0/PKG-INFO +202 -0
  4. agent_email_guard-0.1.0/README.md +169 -0
  5. agent_email_guard-0.1.0/examples/agent_tool.py +57 -0
  6. agent_email_guard-0.1.0/examples/agentisend_sdk.py +30 -0
  7. agent_email_guard-0.1.0/examples/boto3_ses.py +43 -0
  8. agent_email_guard-0.1.0/examples/resend_sdk.py +29 -0
  9. agent_email_guard-0.1.0/examples/smtp_stdlib.py +49 -0
  10. agent_email_guard-0.1.0/pyproject.toml +48 -0
  11. agent_email_guard-0.1.0/src/agent_email_guard/__init__.py +58 -0
  12. agent_email_guard-0.1.0/src/agent_email_guard/_version.py +1 -0
  13. agent_email_guard-0.1.0/src/agent_email_guard/agentisend.py +300 -0
  14. agent_email_guard-0.1.0/src/agent_email_guard/decisions.py +119 -0
  15. agent_email_guard-0.1.0/src/agent_email_guard/errors.py +38 -0
  16. agent_email_guard-0.1.0/src/agent_email_guard/guard.py +613 -0
  17. agent_email_guard-0.1.0/src/agent_email_guard/loop.py +286 -0
  18. agent_email_guard-0.1.0/src/agent_email_guard/messages.py +103 -0
  19. agent_email_guard-0.1.0/src/agent_email_guard/policy.py +116 -0
  20. agent_email_guard-0.1.0/src/agent_email_guard/py.typed +0 -0
  21. agent_email_guard-0.1.0/src/agent_email_guard/stores/__init__.py +10 -0
  22. agent_email_guard-0.1.0/src/agent_email_guard/stores/base.py +96 -0
  23. agent_email_guard-0.1.0/src/agent_email_guard/stores/memory.py +119 -0
  24. agent_email_guard-0.1.0/src/agent_email_guard/stores/postgres.py +250 -0
  25. agent_email_guard-0.1.0/src/agent_email_guard/stores/redis.py +192 -0
  26. agent_email_guard-0.1.0/src/agent_email_guard/text.py +93 -0
  27. agent_email_guard-0.1.0/tests/conftest.py +16 -0
  28. agent_email_guard-0.1.0/tests/test_agentisend_adapter.py +138 -0
  29. agent_email_guard-0.1.0/tests/test_b08_budget_race.py +124 -0
  30. agent_email_guard-0.1.0/tests/test_diverse_traffic_not_flagged.py +81 -0
  31. agent_email_guard-0.1.0/tests/test_entropy_collapse_independent.py +67 -0
  32. agent_email_guard-0.1.0/tests/test_examples.py +107 -0
  33. agent_email_guard-0.1.0/tests/test_guard_api.py +170 -0
  34. agent_email_guard-0.1.0/tests/test_kill_switch_no_auto_resume.py +74 -0
  35. agent_email_guard-0.1.0/tests/test_loop_4000_repetition.py +49 -0
  36. agent_email_guard-0.1.0/tests/test_loop_mode_repetition_only.py +42 -0
  37. agent_email_guard-0.1.0/tests/test_policy_file.py +68 -0
  38. agent_email_guard-0.1.0/tests/test_readme.py +29 -0
  39. agent_email_guard-0.1.0/tests/test_refusal_tokens.py +72 -0
  40. agent_email_guard-0.1.0/tests/test_sp04_entropy_collapse_dominant_only.py +70 -0
  41. agent_email_guard-0.1.0/tests/test_spec_vectors.py +110 -0
  42. agent_email_guard-0.1.0/tests/test_stores.py +215 -0
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ dist/
3
+ build/
4
+ *.egg-info/
5
+ __pycache__/
6
+ .pytest_cache/
7
+ .venv39/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AgentiSend
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,202 @@
1
+ Metadata-Version: 2.5
2
+ Name: agent-email-guard
3
+ Version: 0.1.0
4
+ Summary: Blast-radius control for agents that send email: budgets, a rate ceiling, loop detection, approval holds and a kill switch, in front of any provider.
5
+ Project-URL: Homepage, https://agentisend.com
6
+ Project-URL: Source, https://github.com/agentisend/agent-email-guard
7
+ Project-URL: Issues, https://github.com/agentisend/agent-email-guard/issues
8
+ Author-email: AgentiSend <hello@agentisend.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai-agents,budget,email,guardrails,kill-switch,loop-detection,mcp,rate-limit,resend-alternative
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Communications :: Email
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.9
25
+ Provides-Extra: dev
26
+ Requires-Dist: fakeredis[lua]>=2.20; extra == 'dev'
27
+ Requires-Dist: pytest>=7; extra == 'dev'
28
+ Provides-Extra: postgres
29
+ Requires-Dist: psycopg>=3.1; extra == 'postgres'
30
+ Provides-Extra: redis
31
+ Requires-Dist: redis>=4.2; extra == 'redis'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # agent-email-guard
35
+
36
+ Blast-radius control for agents that send email. It caps how much an agent can send and to whom, stops loops, holds chosen sends for a person, and gives you one call that stops everything. It works in front of any provider: AgentiSend, Resend, Amazon SES, SMTP, or your own function.
37
+
38
+ It limits volume, repetition and recipients. It does not read intent or detect prompt injection; an agent tricked into sending one bad email within its limits still sends it.
39
+
40
+ ```bash
41
+ pip install agent-email-guard
42
+ ```
43
+
44
+ Python 3.9 or newer. No runtime dependencies. Redis and Postgres stores are optional extras.
45
+
46
+ ## Twelve lines
47
+
48
+ ```python
49
+ from agent_email_guard import ApprovalRequired, Guard, GuardRefused
50
+
51
+ guard = Guard(
52
+ budgets={"per_hour": 50, "per_day": 500},
53
+ rate={"per_minute": 10},
54
+ suppress=["@competitor.example"],
55
+ )
56
+ send = guard.wrap(provider.send) # any function that takes one email dict
57
+
58
+ try:
59
+ send({"to": "customer@example.com", "subject": "Your receipt", "text": "Thanks."})
60
+ except (GuardRefused, ApprovalRequired) as stop:
61
+ print(stop.decision.reason, stop.decision.message)
62
+ ```
63
+
64
+ `wrap()` keeps the function's signature. Each call is checked, sent, and recorded. If the provider raises, the reservation is given back and the provider's error reaches you unchanged.
65
+
66
+ ## What it decides
67
+
68
+ Every check returns a `Decision` with four fields. `outcome` is `allow`, `hold_for_approval` or `refuse`. `reason` is a stable token. `message` is one sentence a person can read. `evidence` holds the numbers behind it:
69
+
70
+ ```python
71
+ >>> guard.check({"to": "ops@example.com", "subject": "Disk at 91%", "text": "db-1"})
72
+ Decision(outcome='refuse', reason='loop_repetition_detected',
73
+ message='Blocked: 3 near-identical emails already went to ops@example.com within 60 minutes, which matches a retry or script loop; review the agent before sending more.',
74
+ evidence={'matching_sends_in_window': 3, 'repetition_threshold': 3, 'window_ms': 3600000, ...})
75
+ ```
76
+
77
+ Checks run in this order and stop at the first block:
78
+
79
+ | # | Check | Outcome | Reason |
80
+ |---|---|---|---|
81
+ | 1 | Kill switch | refuse | `kill_switch_active` |
82
+ | 2 | Suppression list | refuse | `recipient_suppressed` |
83
+ | 3 | Loop detection | refuse | `loop_repetition_detected`, `recipient_entropy_collapse` |
84
+ | 4 | Approval rules | hold | `recipient_requires_approval` |
85
+ | 5 | Rate ceiling (sliding minute) | refuse | `rate_ceiling_exceeded` |
86
+ | 6 | Budgets (UTC hour, day, month) | refuse | `agent_budget_exceeded` |
87
+ | 7 | | allow | `ok` |
88
+
89
+ Steps 1 to 4 reserve nothing. Steps 5 and 6 reserve atomically: 200 concurrent checks against a budget of 50 allow exactly 50 (the test suite runs this with threads and with asyncio).
90
+
91
+ **Loop detection** uses two signals. The first is repetition: the agent has already sent 3 emails to the same To line within 60 minutes, with subject and body at least 85% similar (trigram Jaccard). The second is recipient collapse: across 20 or more recent sends, the effective number of recipients has dropped below 3. A collapsed window holds only a send to one of the inboxes it collapsed on. A sign-in code to a new user still goes out. Both signals and all five thresholds are configurable (`LOOP_DEFAULTS`). `loop={"mode": "repetition_only"}` turns off the collapse signal, and `loop=False` turns off both.
92
+
93
+ **Approvals.** `approval={"recipients": ["@bigcustomer.com"]}` holds any send to that domain. `approval={"new_recipients": True}` holds a send to any address this key has never sent to. `approval={"require_for": fn}` holds a send whenever your function returns something truthy. Keep held sends wherever you like, then call `stop.approve()` once a person says yes. Every other check runs again before the email goes out.
94
+
95
+ **Kill switch.** `guard.kill_switch.pause("looping on one inbox", by="ops")` stops every send through this guard, for every key, until someone calls `guard.kill_switch.resume("ops")`. There is no timer. A paused guard is still paused a year later.
96
+
97
+ **Keys.** A send can name the agent it belongs to with `"key": "billing-agent"`. Each key gets its own budget, rate window, loop window and seen-recipient memory.
98
+
99
+ ## Policy file
100
+
101
+ Keep the limits in `agent-email-guard.json` next to your code instead of inside it. The same file configures the Node, PHP and Go ports. An unknown key or a wrong type rejects the whole file with a sentence naming the key.
102
+
103
+ ```json
104
+ {
105
+ "version": 1,
106
+ "key": "support-agent",
107
+ "budgets": {"per_hour": 50, "per_day": 500},
108
+ "rate": {"per_minute": 10},
109
+ "approval": {"new_recipients": true, "recipients": ["@bigcustomer.com"]},
110
+ "suppress": ["@competitor.example"]
111
+ }
112
+ ```
113
+
114
+ ```python
115
+ guard = Guard.from_policy() # reads ./agent-email-guard.json
116
+ guard = Guard.from_policy("config/agent-email-guard.json", require_for=my_rule, store=store)
117
+ ```
118
+
119
+ `require_for` is code, so it is passed in, never read from the file. Keyword arguments override the file.
120
+
121
+ ## Advisory here, enforced at AgentiSend
122
+
123
+ This library runs inside the agent's own process. That makes it advisory. An agent that edits its own code, skips `check()`, or starts a second process with a fresh in-memory store can get around it. For many teams that is enough: the failure it catches is the agent stuck in a retry loop, not an agent fighting its operator.
124
+
125
+ When you need limits the agent cannot remove, AgentiSend enforces the same rules at its API, on the API key itself. The rules are budget, per-minute ceiling, loop detection, approval queue and kill switch. Every process using that key shares them, and a paused key cannot resume itself: only a person signed in to the console can.
126
+
127
+ ```python
128
+ from agent_email_guard.agentisend import AgentiSendGuard
129
+
130
+ hosted = AgentiSendGuard(key_id="your-api-key-id") # reads AGENTISEND_API_KEY
131
+ hosted.apply_limits(budgets={"per_day": 500}, rate={"per_minute": 10})
132
+ hosted.check(email) # POST /emails/preflight: runs every gate, sends nothing
133
+ hosted.send(email) # POST /emails: raises GuardRefused / ApprovalRequired like the local guard
134
+ hosted.kill_switch.pause("looping")
135
+ hosted.approvals.list() # held sends waiting for a person
136
+ ```
137
+
138
+ A key can lower its own limits. Raising a limit is a person's decision, made in the console.
139
+
140
+ ## Integrations
141
+
142
+ Each one is a runnable file in [`examples/`](examples/). The test suite runs every example against a stub.
143
+
144
+ - **AgentiSend** ([`agentisend_sdk.py`](examples/agentisend_sdk.py)): `guard.wrap(AgentiSend().send_email)`, with the hosted adapter above for enforcement.
145
+ - **Resend** ([`resend_sdk.py`](examples/resend_sdk.py)): `guard.wrap(resend.Emails.send)`.
146
+ - **Amazon SES** ([`boto3_ses.py`](examples/boto3_ses.py)): `guard.wrap(ses.send_email, extract=...)`, which maps SES's keyword arguments to a send.
147
+ - **SMTP** ([`smtp_stdlib.py`](examples/smtp_stdlib.py)): `smtplib` and `email.message`, standard library only.
148
+ - **LangChain and the OpenAI Agents SDK** ([`agent_tool.py`](examples/agent_tool.py)): a `send_email` tool that returns the guard's sentence instead of raising. The model reads why a send was refused and does not retry in a loop.
149
+
150
+ ## Sharing limits across processes
151
+
152
+ The in-memory store counts for one process. To share budgets, the rate window, the loop log and the kill switch across processes, use one of these:
153
+
154
+ ```python
155
+ # pip install "agent-email-guard[redis]"
156
+ import redis
157
+ from agent_email_guard.stores.redis import RedisStore
158
+ guard = Guard(budgets={"per_day": 500}, store=RedisStore(redis.Redis.from_url("redis://localhost:6379/0")))
159
+
160
+ # pip install "agent-email-guard[postgres]"
161
+ import psycopg
162
+ from agent_email_guard.stores.postgres import PostgresStore
163
+ store = PostgresStore(psycopg.connect("postgresql://localhost/app", autocommit=True))
164
+ store.setup() # creates four agent_email_guard_* tables if missing
165
+ guard = Guard(budgets={"per_day": 500}, store=store)
166
+ ```
167
+
168
+ Redis runs every check-and-reserve as one Lua script. Postgres uses one conditional upsert per counter, plus an advisory lock around the rate window. Store key names are shared with the Node, PHP and Go ports of agent-email-guard, so agents written in different languages can share one budget.
169
+
170
+ ## asyncio
171
+
172
+ ```python
173
+ from agent_email_guard import AsyncGuard
174
+ from agent_email_guard.stores.redis import AsyncRedisStore # or AsyncPostgresStore, or the default in-memory store
175
+
176
+ guard = AsyncGuard(budgets={"per_day": 500}, rate={"per_minute": 10})
177
+ send = guard.wrap(async_provider_send) # wraps an async function
178
+ decision = await guard.check(email) # require_for may be async here
179
+ ```
180
+
181
+ ## Without wrap
182
+
183
+ ```python
184
+ decision = guard.check(email)
185
+ if decision.outcome == "allow":
186
+ try:
187
+ provider.send(email)
188
+ except Exception:
189
+ guard.release(decision) # give back the reservation
190
+ raise
191
+ guard.record(email) # feeds loop detection and new-recipient memory
192
+ ```
193
+
194
+ `decision.reservation.to_dict()` is plain JSON, so a decision can be released from a different process.
195
+
196
+ ## Conformance
197
+
198
+ Every port of agent-email-guard (Node, Python, PHP, Go) passes the same 45 conformance cases in [`../spec/vectors`](../spec/vectors). They pin each reason token, each sentence byte for byte, and each evidence field. The specification is [`../spec/SPEC.md`](../spec/SPEC.md).
199
+
200
+ ## License
201
+
202
+ MIT. Copyright AgentiSend.
@@ -0,0 +1,169 @@
1
+ # agent-email-guard
2
+
3
+ Blast-radius control for agents that send email. It caps how much an agent can send and to whom, stops loops, holds chosen sends for a person, and gives you one call that stops everything. It works in front of any provider: AgentiSend, Resend, Amazon SES, SMTP, or your own function.
4
+
5
+ It limits volume, repetition and recipients. It does not read intent or detect prompt injection; an agent tricked into sending one bad email within its limits still sends it.
6
+
7
+ ```bash
8
+ pip install agent-email-guard
9
+ ```
10
+
11
+ Python 3.9 or newer. No runtime dependencies. Redis and Postgres stores are optional extras.
12
+
13
+ ## Twelve lines
14
+
15
+ ```python
16
+ from agent_email_guard import ApprovalRequired, Guard, GuardRefused
17
+
18
+ guard = Guard(
19
+ budgets={"per_hour": 50, "per_day": 500},
20
+ rate={"per_minute": 10},
21
+ suppress=["@competitor.example"],
22
+ )
23
+ send = guard.wrap(provider.send) # any function that takes one email dict
24
+
25
+ try:
26
+ send({"to": "customer@example.com", "subject": "Your receipt", "text": "Thanks."})
27
+ except (GuardRefused, ApprovalRequired) as stop:
28
+ print(stop.decision.reason, stop.decision.message)
29
+ ```
30
+
31
+ `wrap()` keeps the function's signature. Each call is checked, sent, and recorded. If the provider raises, the reservation is given back and the provider's error reaches you unchanged.
32
+
33
+ ## What it decides
34
+
35
+ Every check returns a `Decision` with four fields. `outcome` is `allow`, `hold_for_approval` or `refuse`. `reason` is a stable token. `message` is one sentence a person can read. `evidence` holds the numbers behind it:
36
+
37
+ ```python
38
+ >>> guard.check({"to": "ops@example.com", "subject": "Disk at 91%", "text": "db-1"})
39
+ Decision(outcome='refuse', reason='loop_repetition_detected',
40
+ message='Blocked: 3 near-identical emails already went to ops@example.com within 60 minutes, which matches a retry or script loop; review the agent before sending more.',
41
+ evidence={'matching_sends_in_window': 3, 'repetition_threshold': 3, 'window_ms': 3600000, ...})
42
+ ```
43
+
44
+ Checks run in this order and stop at the first block:
45
+
46
+ | # | Check | Outcome | Reason |
47
+ |---|---|---|---|
48
+ | 1 | Kill switch | refuse | `kill_switch_active` |
49
+ | 2 | Suppression list | refuse | `recipient_suppressed` |
50
+ | 3 | Loop detection | refuse | `loop_repetition_detected`, `recipient_entropy_collapse` |
51
+ | 4 | Approval rules | hold | `recipient_requires_approval` |
52
+ | 5 | Rate ceiling (sliding minute) | refuse | `rate_ceiling_exceeded` |
53
+ | 6 | Budgets (UTC hour, day, month) | refuse | `agent_budget_exceeded` |
54
+ | 7 | | allow | `ok` |
55
+
56
+ Steps 1 to 4 reserve nothing. Steps 5 and 6 reserve atomically: 200 concurrent checks against a budget of 50 allow exactly 50 (the test suite runs this with threads and with asyncio).
57
+
58
+ **Loop detection** uses two signals. The first is repetition: the agent has already sent 3 emails to the same To line within 60 minutes, with subject and body at least 85% similar (trigram Jaccard). The second is recipient collapse: across 20 or more recent sends, the effective number of recipients has dropped below 3. A collapsed window holds only a send to one of the inboxes it collapsed on. A sign-in code to a new user still goes out. Both signals and all five thresholds are configurable (`LOOP_DEFAULTS`). `loop={"mode": "repetition_only"}` turns off the collapse signal, and `loop=False` turns off both.
59
+
60
+ **Approvals.** `approval={"recipients": ["@bigcustomer.com"]}` holds any send to that domain. `approval={"new_recipients": True}` holds a send to any address this key has never sent to. `approval={"require_for": fn}` holds a send whenever your function returns something truthy. Keep held sends wherever you like, then call `stop.approve()` once a person says yes. Every other check runs again before the email goes out.
61
+
62
+ **Kill switch.** `guard.kill_switch.pause("looping on one inbox", by="ops")` stops every send through this guard, for every key, until someone calls `guard.kill_switch.resume("ops")`. There is no timer. A paused guard is still paused a year later.
63
+
64
+ **Keys.** A send can name the agent it belongs to with `"key": "billing-agent"`. Each key gets its own budget, rate window, loop window and seen-recipient memory.
65
+
66
+ ## Policy file
67
+
68
+ Keep the limits in `agent-email-guard.json` next to your code instead of inside it. The same file configures the Node, PHP and Go ports. An unknown key or a wrong type rejects the whole file with a sentence naming the key.
69
+
70
+ ```json
71
+ {
72
+ "version": 1,
73
+ "key": "support-agent",
74
+ "budgets": {"per_hour": 50, "per_day": 500},
75
+ "rate": {"per_minute": 10},
76
+ "approval": {"new_recipients": true, "recipients": ["@bigcustomer.com"]},
77
+ "suppress": ["@competitor.example"]
78
+ }
79
+ ```
80
+
81
+ ```python
82
+ guard = Guard.from_policy() # reads ./agent-email-guard.json
83
+ guard = Guard.from_policy("config/agent-email-guard.json", require_for=my_rule, store=store)
84
+ ```
85
+
86
+ `require_for` is code, so it is passed in, never read from the file. Keyword arguments override the file.
87
+
88
+ ## Advisory here, enforced at AgentiSend
89
+
90
+ This library runs inside the agent's own process. That makes it advisory. An agent that edits its own code, skips `check()`, or starts a second process with a fresh in-memory store can get around it. For many teams that is enough: the failure it catches is the agent stuck in a retry loop, not an agent fighting its operator.
91
+
92
+ When you need limits the agent cannot remove, AgentiSend enforces the same rules at its API, on the API key itself. The rules are budget, per-minute ceiling, loop detection, approval queue and kill switch. Every process using that key shares them, and a paused key cannot resume itself: only a person signed in to the console can.
93
+
94
+ ```python
95
+ from agent_email_guard.agentisend import AgentiSendGuard
96
+
97
+ hosted = AgentiSendGuard(key_id="your-api-key-id") # reads AGENTISEND_API_KEY
98
+ hosted.apply_limits(budgets={"per_day": 500}, rate={"per_minute": 10})
99
+ hosted.check(email) # POST /emails/preflight: runs every gate, sends nothing
100
+ hosted.send(email) # POST /emails: raises GuardRefused / ApprovalRequired like the local guard
101
+ hosted.kill_switch.pause("looping")
102
+ hosted.approvals.list() # held sends waiting for a person
103
+ ```
104
+
105
+ A key can lower its own limits. Raising a limit is a person's decision, made in the console.
106
+
107
+ ## Integrations
108
+
109
+ Each one is a runnable file in [`examples/`](examples/). The test suite runs every example against a stub.
110
+
111
+ - **AgentiSend** ([`agentisend_sdk.py`](examples/agentisend_sdk.py)): `guard.wrap(AgentiSend().send_email)`, with the hosted adapter above for enforcement.
112
+ - **Resend** ([`resend_sdk.py`](examples/resend_sdk.py)): `guard.wrap(resend.Emails.send)`.
113
+ - **Amazon SES** ([`boto3_ses.py`](examples/boto3_ses.py)): `guard.wrap(ses.send_email, extract=...)`, which maps SES's keyword arguments to a send.
114
+ - **SMTP** ([`smtp_stdlib.py`](examples/smtp_stdlib.py)): `smtplib` and `email.message`, standard library only.
115
+ - **LangChain and the OpenAI Agents SDK** ([`agent_tool.py`](examples/agent_tool.py)): a `send_email` tool that returns the guard's sentence instead of raising. The model reads why a send was refused and does not retry in a loop.
116
+
117
+ ## Sharing limits across processes
118
+
119
+ The in-memory store counts for one process. To share budgets, the rate window, the loop log and the kill switch across processes, use one of these:
120
+
121
+ ```python
122
+ # pip install "agent-email-guard[redis]"
123
+ import redis
124
+ from agent_email_guard.stores.redis import RedisStore
125
+ guard = Guard(budgets={"per_day": 500}, store=RedisStore(redis.Redis.from_url("redis://localhost:6379/0")))
126
+
127
+ # pip install "agent-email-guard[postgres]"
128
+ import psycopg
129
+ from agent_email_guard.stores.postgres import PostgresStore
130
+ store = PostgresStore(psycopg.connect("postgresql://localhost/app", autocommit=True))
131
+ store.setup() # creates four agent_email_guard_* tables if missing
132
+ guard = Guard(budgets={"per_day": 500}, store=store)
133
+ ```
134
+
135
+ Redis runs every check-and-reserve as one Lua script. Postgres uses one conditional upsert per counter, plus an advisory lock around the rate window. Store key names are shared with the Node, PHP and Go ports of agent-email-guard, so agents written in different languages can share one budget.
136
+
137
+ ## asyncio
138
+
139
+ ```python
140
+ from agent_email_guard import AsyncGuard
141
+ from agent_email_guard.stores.redis import AsyncRedisStore # or AsyncPostgresStore, or the default in-memory store
142
+
143
+ guard = AsyncGuard(budgets={"per_day": 500}, rate={"per_minute": 10})
144
+ send = guard.wrap(async_provider_send) # wraps an async function
145
+ decision = await guard.check(email) # require_for may be async here
146
+ ```
147
+
148
+ ## Without wrap
149
+
150
+ ```python
151
+ decision = guard.check(email)
152
+ if decision.outcome == "allow":
153
+ try:
154
+ provider.send(email)
155
+ except Exception:
156
+ guard.release(decision) # give back the reservation
157
+ raise
158
+ guard.record(email) # feeds loop detection and new-recipient memory
159
+ ```
160
+
161
+ `decision.reservation.to_dict()` is plain JSON, so a decision can be released from a different process.
162
+
163
+ ## Conformance
164
+
165
+ Every port of agent-email-guard (Node, Python, PHP, Go) passes the same 45 conformance cases in [`../spec/vectors`](../spec/vectors). They pin each reason token, each sentence byte for byte, and each evidence field. The specification is [`../spec/SPEC.md`](../spec/SPEC.md).
166
+
167
+ ## License
168
+
169
+ MIT. Copyright AgentiSend.
@@ -0,0 +1,57 @@
1
+ """A send-email tool for an LLM agent, guarded (LangChain or the OpenAI Agents SDK).
2
+
3
+ The tool returns the guard's sentence instead of raising, so the model reads
4
+ why a send was refused or held and can tell the user, rather than retrying
5
+ in a loop. Register the same function with either framework:
6
+
7
+ pip install agent-email-guard langchain-core # LangChain
8
+ pip install agent-email-guard openai-agents # OpenAI Agents SDK
9
+ """
10
+
11
+ from agent_email_guard import ApprovalRequired, Guard, GuardRefused
12
+
13
+ guard = Guard(
14
+ budgets={"per_hour": 20, "per_day": 100},
15
+ rate={"per_minute": 5},
16
+ approval={"new_recipients": True},
17
+ )
18
+
19
+ #: Held sends, keyed by the recipient, for a person to approve later.
20
+ held = {}
21
+
22
+
23
+ def make_tool(provider_send, guard=guard):
24
+ guarded = guard.wrap(provider_send)
25
+
26
+ def send_email(to: str, subject: str, body: str) -> str:
27
+ """Send one email. Returns what happened, in a sentence."""
28
+ try:
29
+ result = guarded({"to": to, "subject": subject, "text": body})
30
+ return f"Sent ({result})."
31
+ except GuardRefused as stop:
32
+ return f"Not sent: {stop.decision.message}"
33
+ except ApprovalRequired as wait:
34
+ held[to] = wait
35
+ return f"Waiting for approval: {wait.decision.message}"
36
+
37
+ return send_email
38
+
39
+
40
+ def as_langchain_tool(fn):
41
+ from langchain_core.tools import StructuredTool
42
+
43
+ return StructuredTool.from_function(fn, name="send_email", description=fn.__doc__)
44
+
45
+
46
+ def as_openai_agents_tool(fn):
47
+ from agents import function_tool
48
+
49
+ return function_tool(fn)
50
+
51
+
52
+ if __name__ == "__main__":
53
+ from agentisend import AgentiSend
54
+
55
+ client = AgentiSend()
56
+ tool_fn = make_tool(lambda p: client.send_email({"from": "agent@acme.dev", **p})["id"])
57
+ print(tool_fn("someone@example.com", "Hello", "First message."))
@@ -0,0 +1,30 @@
1
+ """agent-email-guard in front of the AgentiSend Python SDK (pip install agentisend agent-email-guard).
2
+
3
+ Two layers: the local guard stops a runaway agent before a request leaves the
4
+ process, and AgentiSend enforces the key's own limits at the API, where the
5
+ agent cannot remove them. Run: AGENTISEND_API_KEY=... python agentisend_sdk.py
6
+ """
7
+
8
+ from agent_email_guard import ApprovalRequired, Guard, GuardRefused
9
+
10
+
11
+ def build_send(client, guard=None):
12
+ guard = guard or Guard(budgets={"per_hour": 50, "per_day": 500}, rate={"per_minute": 10})
13
+ return guard.wrap(client.send_email)
14
+
15
+
16
+ def main():
17
+ from agentisend import AgentiSend
18
+
19
+ send = build_send(AgentiSend()) # reads AGENTISEND_API_KEY
20
+ try:
21
+ result = send({"from": "Acme <billing@acme.dev>", "to": "customer@example.com", "subject": "Your receipt", "text": "Thanks for your order."})
22
+ print("sent", result["id"])
23
+ except GuardRefused as stop:
24
+ print("refused:", stop.decision.reason, "-", stop.decision.message)
25
+ except ApprovalRequired as held:
26
+ print("held for a person:", held.decision.message)
27
+
28
+
29
+ if __name__ == "__main__":
30
+ main()
@@ -0,0 +1,43 @@
1
+ """agent-email-guard in front of Amazon SES via boto3 (pip install boto3 agent-email-guard).
2
+
3
+ SES's send_email takes keyword arguments in its own shape, so ``extract``
4
+ maps them to the guard's send. Run with AWS credentials configured.
5
+ """
6
+
7
+ from agent_email_guard import Guard, GuardRefused
8
+
9
+
10
+ def ses_to_send(**kwargs):
11
+ dest = kwargs.get("Destination", {})
12
+ body = kwargs.get("Message", {}).get("Body", {})
13
+ return {
14
+ "to": dest.get("ToAddresses", []),
15
+ "cc": dest.get("CcAddresses", []),
16
+ "bcc": dest.get("BccAddresses", []),
17
+ "subject": kwargs.get("Message", {}).get("Subject", {}).get("Data", ""),
18
+ "text": body.get("Text", {}).get("Data"),
19
+ "html": body.get("Html", {}).get("Data"),
20
+ }
21
+
22
+
23
+ def build_send(ses_client, guard=None):
24
+ guard = guard or Guard(budgets={"per_day": 1000}, rate={"per_minute": 14})
25
+ return guard.wrap(ses_client.send_email, extract=ses_to_send)
26
+
27
+
28
+ def main():
29
+ import boto3
30
+
31
+ send = build_send(boto3.client("ses", region_name="us-east-1"))
32
+ try:
33
+ send(
34
+ Source="billing@acme.dev",
35
+ Destination={"ToAddresses": ["customer@example.com"]},
36
+ Message={"Subject": {"Data": "Your invoice"}, "Body": {"Text": {"Data": "Invoice attached."}}},
37
+ )
38
+ except GuardRefused as stop:
39
+ print(stop.decision.message)
40
+
41
+
42
+ if __name__ == "__main__":
43
+ main()
@@ -0,0 +1,29 @@
1
+ """agent-email-guard in front of the Resend Python SDK (pip install resend agent-email-guard).
2
+
3
+ resend.Emails.send(params) keeps its signature; the guard checks first and
4
+ records only what Resend accepted. Run: RESEND_API_KEY=... python resend_sdk.py
5
+ """
6
+
7
+ from agent_email_guard import Guard, GuardRefused
8
+
9
+
10
+ def build_send(emails_api, guard=None):
11
+ guard = guard or Guard(budgets={"per_day": 500}, rate={"per_minute": 10}, approval={"recipients": ["@bigcustomer.com"]})
12
+ return guard.wrap(emails_api.send)
13
+
14
+
15
+ def main():
16
+ import os
17
+
18
+ import resend
19
+
20
+ resend.api_key = os.environ["RESEND_API_KEY"]
21
+ send = build_send(resend.Emails)
22
+ try:
23
+ print(send({"from": "Acme <onboarding@acme.dev>", "to": ["new-user@example.com"], "subject": "Welcome", "html": "<p>Hi!</p>"}))
24
+ except GuardRefused as stop:
25
+ print(stop.decision.reason, stop.decision.message)
26
+
27
+
28
+ if __name__ == "__main__":
29
+ main()
@@ -0,0 +1,49 @@
1
+ """agent-email-guard in front of plain SMTP with the standard library (pip install agent-email-guard).
2
+
3
+ Works with any SMTP server. Run: SMTP_HOST=... SMTP_USER=... SMTP_PASSWORD=... python smtp_stdlib.py
4
+ """
5
+
6
+ import os
7
+ import smtplib
8
+ from email.message import EmailMessage
9
+
10
+ from agent_email_guard import Guard, GuardRefused
11
+
12
+
13
+ def message_to_send(msg):
14
+ return {
15
+ "to": msg.get_all("To", []),
16
+ "cc": msg.get_all("Cc", []),
17
+ "subject": msg.get("Subject", ""),
18
+ "text": msg.get_content() if not msg.is_multipart() else "",
19
+ }
20
+
21
+
22
+ def build_send(smtp_factory, guard=None):
23
+ guard = guard or Guard(budgets={"per_hour": 100}, rate={"per_minute": 20})
24
+
25
+ def deliver(msg):
26
+ with smtp_factory() as smtp:
27
+ smtp.send_message(msg)
28
+
29
+ return guard.wrap(deliver, extract=message_to_send)
30
+
31
+
32
+ def main():
33
+ def factory():
34
+ smtp = smtplib.SMTP(os.environ["SMTP_HOST"], int(os.environ.get("SMTP_PORT", "587")))
35
+ smtp.starttls()
36
+ smtp.login(os.environ["SMTP_USER"], os.environ["SMTP_PASSWORD"])
37
+ return smtp
38
+
39
+ msg = EmailMessage()
40
+ msg["From"], msg["To"], msg["Subject"] = "alerts@acme.dev", "oncall@acme.dev", "Disk at 91%"
41
+ msg.set_content("db-1 crossed 90% disk.")
42
+ try:
43
+ build_send(factory)(msg)
44
+ except GuardRefused as stop:
45
+ print(stop.decision.message)
46
+
47
+
48
+ if __name__ == "__main__":
49
+ main()
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.18"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "agent-email-guard"
7
+ version = "0.1.0"
8
+ description = "Blast-radius control for agents that send email: budgets, a rate ceiling, loop detection, approval holds and a kill switch, in front of any provider."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "AgentiSend", email = "hello@agentisend.com" }]
14
+ keywords = ["ai-agents", "email", "guardrails", "rate-limit", "mcp", "resend-alternative", "budget", "loop-detection", "kill-switch"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Communications :: Email",
27
+ "Typing :: Typed",
28
+ ]
29
+ dependencies = []
30
+
31
+ [project.optional-dependencies]
32
+ redis = ["redis>=4.2"]
33
+ postgres = ["psycopg>=3.1"]
34
+ dev = ["pytest>=7", "fakeredis[lua]>=2.20"]
35
+
36
+ [project.urls]
37
+ Homepage = "https://agentisend.com"
38
+ Source = "https://github.com/agentisend/agent-email-guard"
39
+ Issues = "https://github.com/agentisend/agent-email-guard/issues"
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["src/agent_email_guard"]
43
+
44
+ [tool.hatch.build.targets.sdist]
45
+ include = ["src/agent_email_guard", "tests", "examples", "README.md", "LICENSE", "pyproject.toml"]
46
+
47
+ [tool.pytest.ini_options]
48
+ testpaths = ["tests"]