mailsai 0.1.1__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.
- mailsai-0.1.1/LICENSE +21 -0
- mailsai-0.1.1/PKG-INFO +145 -0
- mailsai-0.1.1/README.md +117 -0
- mailsai-0.1.1/mailsai/__init__.py +499 -0
- mailsai-0.1.1/mailsai.egg-info/PKG-INFO +145 -0
- mailsai-0.1.1/mailsai.egg-info/SOURCES.txt +9 -0
- mailsai-0.1.1/mailsai.egg-info/dependency_links.txt +1 -0
- mailsai-0.1.1/mailsai.egg-info/requires.txt +1 -0
- mailsai-0.1.1/mailsai.egg-info/top_level.txt +1 -0
- mailsai-0.1.1/pyproject.toml +36 -0
- mailsai-0.1.1/setup.cfg +4 -0
mailsai-0.1.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mails.ai
|
|
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.
|
mailsai-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mailsai
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Email API for AI agents — Python SDK. Per-agent email addresses on your domain, per-agent reputation, and prompt-injection scanning on every inbound. Send, receive structured inbound replies, and identify agents via REST. Intent + entity extraction is opt-in.
|
|
5
|
+
Author-email: "Mails.ai" <support@mails.ai>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://mails.ai
|
|
8
|
+
Project-URL: Documentation, https://mails.ai/docs
|
|
9
|
+
Project-URL: Source, https://github.com/RolloutsAI/mails-api
|
|
10
|
+
Project-URL: Issues, https://github.com/RolloutsAI/mails-api/issues
|
|
11
|
+
Keywords: email,ai,agent,mcp,mails.ai,agent-identity,sender-reputation,agent-email,prompt-injection,transactional-email
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Communications :: Email
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: httpx>=0.24.0
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# mailsai
|
|
30
|
+
|
|
31
|
+
Email API for AI agents — Python SDK.
|
|
32
|
+
|
|
33
|
+
Per-agent email addresses on your domain, per-agent reputation, and prompt-injection scanning on every inbound. Your agent reads the reply and decides what to say — intent + entity extraction is opt-in.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install mailsai
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
40
|
+
|
|
41
|
+
Create the agent once (this mints its inbox address), then send from it. Skipping the
|
|
42
|
+
create step is the most common first-run error — `agent("sarah")` only *references* an
|
|
43
|
+
agent, it does not create one, so sending returns `404 agent_not_found`.
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from mailsai import Client, agent
|
|
47
|
+
|
|
48
|
+
# Reads MAILS_API_KEY from env (or pass api_key=... explicitly)
|
|
49
|
+
client = Client()
|
|
50
|
+
|
|
51
|
+
# One-time setup: create the agent. It gets sarah@<your-workspace>.mails.ai
|
|
52
|
+
client.create_agent("sarah")
|
|
53
|
+
|
|
54
|
+
sarah = agent("sarah")
|
|
55
|
+
|
|
56
|
+
# mails.ai is transactional-only: mail the recipient asked for. Cold outreach and bulk
|
|
57
|
+
# marketing are refused with 422 cold_email_prohibited — that is deliberate, and it is
|
|
58
|
+
# what keeps the sending reputation clean for everyone on the platform.
|
|
59
|
+
sarah.send(
|
|
60
|
+
to="you@example.com",
|
|
61
|
+
subject="Your verification code is 481902",
|
|
62
|
+
body="Your verification code is 481902. It expires in 15 minutes.",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
@sarah.on_reply
|
|
66
|
+
def handle(reply):
|
|
67
|
+
# Always present — the delivery + security + identity layer:
|
|
68
|
+
# reply["injection_score"] -> 0.02 # six-category prompt-injection scan
|
|
69
|
+
# reply["sender_reputation"] -> 0.91 # per-agent reputation
|
|
70
|
+
#
|
|
71
|
+
# Present only when classification is enabled (opt-in, +$0.003/inbound):
|
|
72
|
+
# reply["intent"] -> "schedule_demo" | "ask_question" | …
|
|
73
|
+
# reply["entities"] -> {"date": "...", "time": "..."}
|
|
74
|
+
# reply["urgency"] -> 0.8
|
|
75
|
+
#
|
|
76
|
+
# Your agent reads the reply and decides what to send next.
|
|
77
|
+
print(reply["injection_score"], reply["sender_reputation"])
|
|
78
|
+
|
|
79
|
+
sarah.start_listening() # blocks; opens SSE stream and dispatches replies
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
For non-blocking, pass `blocking=False` to `start_listening()` — it returns a daemon thread.
|
|
83
|
+
|
|
84
|
+
## Lower-level client
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from mailsai import create_client
|
|
88
|
+
|
|
89
|
+
c = create_client() # reads MAILS_API_KEY
|
|
90
|
+
|
|
91
|
+
# Send via any agent
|
|
92
|
+
c.send("sarah", to="lead@example.com", subject="Demo", body_text="…")
|
|
93
|
+
|
|
94
|
+
# Resources
|
|
95
|
+
threads = c.list_threads(agent_id="agent_abc123")
|
|
96
|
+
usage = c.usage()
|
|
97
|
+
rep = c.get_reputation(agent_id="agent_abc123")
|
|
98
|
+
|
|
99
|
+
# Drafts
|
|
100
|
+
draft = c.create_draft(agent="sarah", to="lead@example.com", subject="Demo", body_text="…")
|
|
101
|
+
c.send_draft(draft["id"])
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Webhook verification
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from mailsai import verify_webhook
|
|
108
|
+
|
|
109
|
+
# In your webhook handler:
|
|
110
|
+
event = verify_webhook(
|
|
111
|
+
body=request.body.decode(),
|
|
112
|
+
signature=request.headers["X-Mails-Signature"],
|
|
113
|
+
secret=os.environ["MAILS_WEBHOOK_SECRET"],
|
|
114
|
+
)
|
|
115
|
+
if event is None:
|
|
116
|
+
return Response(status=400)
|
|
117
|
+
# event is the verified inbound event dict
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Errors
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from mailsai import MailsError
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
sarah.send(to="blocked@example.com", subject="…", body="…")
|
|
127
|
+
except MailsError as e:
|
|
128
|
+
print(e.type, e.code, e.message, e.status, e.request_id)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Configuration
|
|
132
|
+
|
|
133
|
+
| Param / env var | Default |
|
|
134
|
+
|---|---|
|
|
135
|
+
| `api_key` / `MAILS_API_KEY` | (required) |
|
|
136
|
+
| `base_url` / `MAILS_BASE_URL` | `https://api.mails.ai` |
|
|
137
|
+
|
|
138
|
+
## Documentation
|
|
139
|
+
|
|
140
|
+
- Full docs: [mails.ai/docs](https://mails.ai/docs)
|
|
141
|
+
- Source: [github.com/RolloutsAI/mails-api](https://github.com/RolloutsAI/mails-api/tree/main/packages/sdk-py)
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
MIT
|
mailsai-0.1.1/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# mailsai
|
|
2
|
+
|
|
3
|
+
Email API for AI agents — Python SDK.
|
|
4
|
+
|
|
5
|
+
Per-agent email addresses on your domain, per-agent reputation, and prompt-injection scanning on every inbound. Your agent reads the reply and decides what to say — intent + entity extraction is opt-in.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install mailsai
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
Create the agent once (this mints its inbox address), then send from it. Skipping the
|
|
14
|
+
create step is the most common first-run error — `agent("sarah")` only *references* an
|
|
15
|
+
agent, it does not create one, so sending returns `404 agent_not_found`.
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from mailsai import Client, agent
|
|
19
|
+
|
|
20
|
+
# Reads MAILS_API_KEY from env (or pass api_key=... explicitly)
|
|
21
|
+
client = Client()
|
|
22
|
+
|
|
23
|
+
# One-time setup: create the agent. It gets sarah@<your-workspace>.mails.ai
|
|
24
|
+
client.create_agent("sarah")
|
|
25
|
+
|
|
26
|
+
sarah = agent("sarah")
|
|
27
|
+
|
|
28
|
+
# mails.ai is transactional-only: mail the recipient asked for. Cold outreach and bulk
|
|
29
|
+
# marketing are refused with 422 cold_email_prohibited — that is deliberate, and it is
|
|
30
|
+
# what keeps the sending reputation clean for everyone on the platform.
|
|
31
|
+
sarah.send(
|
|
32
|
+
to="you@example.com",
|
|
33
|
+
subject="Your verification code is 481902",
|
|
34
|
+
body="Your verification code is 481902. It expires in 15 minutes.",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
@sarah.on_reply
|
|
38
|
+
def handle(reply):
|
|
39
|
+
# Always present — the delivery + security + identity layer:
|
|
40
|
+
# reply["injection_score"] -> 0.02 # six-category prompt-injection scan
|
|
41
|
+
# reply["sender_reputation"] -> 0.91 # per-agent reputation
|
|
42
|
+
#
|
|
43
|
+
# Present only when classification is enabled (opt-in, +$0.003/inbound):
|
|
44
|
+
# reply["intent"] -> "schedule_demo" | "ask_question" | …
|
|
45
|
+
# reply["entities"] -> {"date": "...", "time": "..."}
|
|
46
|
+
# reply["urgency"] -> 0.8
|
|
47
|
+
#
|
|
48
|
+
# Your agent reads the reply and decides what to send next.
|
|
49
|
+
print(reply["injection_score"], reply["sender_reputation"])
|
|
50
|
+
|
|
51
|
+
sarah.start_listening() # blocks; opens SSE stream and dispatches replies
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
For non-blocking, pass `blocking=False` to `start_listening()` — it returns a daemon thread.
|
|
55
|
+
|
|
56
|
+
## Lower-level client
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from mailsai import create_client
|
|
60
|
+
|
|
61
|
+
c = create_client() # reads MAILS_API_KEY
|
|
62
|
+
|
|
63
|
+
# Send via any agent
|
|
64
|
+
c.send("sarah", to="lead@example.com", subject="Demo", body_text="…")
|
|
65
|
+
|
|
66
|
+
# Resources
|
|
67
|
+
threads = c.list_threads(agent_id="agent_abc123")
|
|
68
|
+
usage = c.usage()
|
|
69
|
+
rep = c.get_reputation(agent_id="agent_abc123")
|
|
70
|
+
|
|
71
|
+
# Drafts
|
|
72
|
+
draft = c.create_draft(agent="sarah", to="lead@example.com", subject="Demo", body_text="…")
|
|
73
|
+
c.send_draft(draft["id"])
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Webhook verification
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from mailsai import verify_webhook
|
|
80
|
+
|
|
81
|
+
# In your webhook handler:
|
|
82
|
+
event = verify_webhook(
|
|
83
|
+
body=request.body.decode(),
|
|
84
|
+
signature=request.headers["X-Mails-Signature"],
|
|
85
|
+
secret=os.environ["MAILS_WEBHOOK_SECRET"],
|
|
86
|
+
)
|
|
87
|
+
if event is None:
|
|
88
|
+
return Response(status=400)
|
|
89
|
+
# event is the verified inbound event dict
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Errors
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from mailsai import MailsError
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
sarah.send(to="blocked@example.com", subject="…", body="…")
|
|
99
|
+
except MailsError as e:
|
|
100
|
+
print(e.type, e.code, e.message, e.status, e.request_id)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Configuration
|
|
104
|
+
|
|
105
|
+
| Param / env var | Default |
|
|
106
|
+
|---|---|
|
|
107
|
+
| `api_key` / `MAILS_API_KEY` | (required) |
|
|
108
|
+
| `base_url` / `MAILS_BASE_URL` | `https://api.mails.ai` |
|
|
109
|
+
|
|
110
|
+
## Documentation
|
|
111
|
+
|
|
112
|
+
- Full docs: [mails.ai/docs](https://mails.ai/docs)
|
|
113
|
+
- Source: [github.com/RolloutsAI/mails-api](https://github.com/RolloutsAI/mails-api/tree/main/packages/sdk-py)
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
MIT
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Mails.ai — Email API for AI agents (Python SDK).
|
|
3
|
+
|
|
4
|
+
Quick start:
|
|
5
|
+
|
|
6
|
+
from mailsai import agent
|
|
7
|
+
|
|
8
|
+
sarah = agent("sarah") # sarah@yourcompany.mails.ai
|
|
9
|
+
sarah.send(to="lead@example.com", subject="Demo", body="Hi…")
|
|
10
|
+
|
|
11
|
+
@sarah.on_reply
|
|
12
|
+
def handle(event):
|
|
13
|
+
# event["intent"] -> "schedule_demo"
|
|
14
|
+
# event["entities"] -> {"date": "...", "time": "..."}
|
|
15
|
+
# event["urgency"] -> 0.8
|
|
16
|
+
# event["injection_score"] -> 0.02
|
|
17
|
+
# event["sender_reputation"] -> 0.91
|
|
18
|
+
print(event["intent"], event["entities"])
|
|
19
|
+
|
|
20
|
+
sarah.start_listening() # blocks; runs SSE consumer
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import hashlib
|
|
26
|
+
import hmac
|
|
27
|
+
import json
|
|
28
|
+
import uuid
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
import threading
|
|
32
|
+
import time
|
|
33
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
|
34
|
+
from urllib.parse import urlencode
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
import httpx # type: ignore
|
|
38
|
+
except ImportError: # pragma: no cover
|
|
39
|
+
httpx = None # streaming + main client both require httpx
|
|
40
|
+
|
|
41
|
+
# Single source of truth is pyproject.toml. A hardcoded literal here silently drifted on
|
|
42
|
+
# the first version bump (metadata said 0.1.1, this said 0.1.0) and it rides the
|
|
43
|
+
# User-Agent header, so the drift also misreports the client version server-side.
|
|
44
|
+
try: # installed normally
|
|
45
|
+
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
|
46
|
+
|
|
47
|
+
__version__ = _pkg_version("mailsai")
|
|
48
|
+
except Exception: # running from a source checkout, or metadata unavailable
|
|
49
|
+
__version__ = "0.1.1"
|
|
50
|
+
__all__ = [
|
|
51
|
+
"agent",
|
|
52
|
+
"Agent",
|
|
53
|
+
"Client",
|
|
54
|
+
"MailsError",
|
|
55
|
+
"verify_webhook",
|
|
56
|
+
"create_client",
|
|
57
|
+
"__version__",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
DEFAULT_BASE = "https://api.mails.ai"
|
|
61
|
+
DEFAULT_TIMEOUT = 30.0
|
|
62
|
+
# Bounded exponential backoff for transient failures (429 / retry-safe 5xx).
|
|
63
|
+
_MAX_RETRIES = 3
|
|
64
|
+
_RETRY_BASE = 0.5
|
|
65
|
+
_RETRY_CAP = 8.0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class MailsError(Exception):
|
|
69
|
+
"""Raised on any non-2xx response from the Mails.ai API."""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
type: str,
|
|
74
|
+
code: str,
|
|
75
|
+
message: str,
|
|
76
|
+
status: int = 0,
|
|
77
|
+
request_id: Optional[str] = None,
|
|
78
|
+
param: Optional[str] = None,
|
|
79
|
+
):
|
|
80
|
+
super().__init__(message)
|
|
81
|
+
self.type = type
|
|
82
|
+
self.code = code
|
|
83
|
+
self.message = message
|
|
84
|
+
self.status = status
|
|
85
|
+
self.request_id = request_id
|
|
86
|
+
self.param = param
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _require_httpx() -> Any:
|
|
90
|
+
if httpx is None:
|
|
91
|
+
raise RuntimeError(
|
|
92
|
+
"The mailsai Python SDK requires httpx. Install it with: pip install 'mailsai[http]' "
|
|
93
|
+
"or pip install httpx"
|
|
94
|
+
)
|
|
95
|
+
return httpx
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Client:
|
|
99
|
+
"""Low-level Mails.ai REST client."""
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
api_key: Optional[str] = None,
|
|
104
|
+
base_url: Optional[str] = None,
|
|
105
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
106
|
+
):
|
|
107
|
+
self.api_key = api_key or os.environ.get("MAILS_API_KEY") or ""
|
|
108
|
+
self.base_url = (base_url or os.environ.get("MAILS_BASE_URL") or DEFAULT_BASE).rstrip("/")
|
|
109
|
+
self.timeout = timeout
|
|
110
|
+
if not self.api_key:
|
|
111
|
+
raise MailsError(
|
|
112
|
+
"invalid_request_error",
|
|
113
|
+
"missing_api_key",
|
|
114
|
+
"MAILS_API_KEY env var or api_key argument required.",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def _headers(self, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
|
|
118
|
+
h = {
|
|
119
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
120
|
+
"Content-Type": "application/json",
|
|
121
|
+
"User-Agent": f"mailsai-python/{__version__}",
|
|
122
|
+
}
|
|
123
|
+
if extra:
|
|
124
|
+
h.update(extra)
|
|
125
|
+
return h
|
|
126
|
+
|
|
127
|
+
def _request(
|
|
128
|
+
self,
|
|
129
|
+
method: str,
|
|
130
|
+
path: str,
|
|
131
|
+
body: Any = None,
|
|
132
|
+
headers: Optional[Dict[str, str]] = None,
|
|
133
|
+
) -> Any:
|
|
134
|
+
httpx_mod = _require_httpx()
|
|
135
|
+
url = f"{self.base_url}{path}"
|
|
136
|
+
merged_headers = self._headers(headers)
|
|
137
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
138
|
+
# Retry-safe when a resend can't double-apply: a 429 was rejected (never
|
|
139
|
+
# processed); 5xx only for read methods or when an Idempotency-Key makes a
|
|
140
|
+
# resend a server-side no-op.
|
|
141
|
+
idempotent_method = method in ("GET", "HEAD")
|
|
142
|
+
has_idem_key = any(k.lower() == "idempotency-key" for k in merged_headers)
|
|
143
|
+
# A POST that reaches the server may be PROCESSED even if we never see the
|
|
144
|
+
# response (timeout, dropped connection). Without an Idempotency-Key the caller's
|
|
145
|
+
# natural reaction — retry — sends the email twice. Generate one so a resend is a
|
|
146
|
+
# server-side no-op. Callers who pass their own key keep full control.
|
|
147
|
+
if method == "POST" and not has_idem_key:
|
|
148
|
+
merged_headers["Idempotency-Key"] = f"sdk-{uuid.uuid4()}"
|
|
149
|
+
has_idem_key = True
|
|
150
|
+
|
|
151
|
+
attempt = 0
|
|
152
|
+
while True:
|
|
153
|
+
try:
|
|
154
|
+
r = httpx_mod.request(
|
|
155
|
+
method, url, headers=merged_headers, content=data, timeout=self.timeout
|
|
156
|
+
)
|
|
157
|
+
except Exception as exc: # httpx.TimeoutException, ConnectError, …
|
|
158
|
+
# Never leak a raw httpx traceback: callers catch MailsError, and a bare
|
|
159
|
+
# transport exception carries no request id and no guidance on whether the
|
|
160
|
+
# call landed. Retry only when a resend cannot double-apply.
|
|
161
|
+
if attempt < _MAX_RETRIES and (idempotent_method or has_idem_key):
|
|
162
|
+
time.sleep(min(_RETRY_CAP, _RETRY_BASE * (2 ** attempt)))
|
|
163
|
+
attempt += 1
|
|
164
|
+
continue
|
|
165
|
+
raise MailsError(
|
|
166
|
+
"connection_error",
|
|
167
|
+
"transport_error",
|
|
168
|
+
f"{method} {path} could not complete: {exc}. The request may or may "
|
|
169
|
+
"not have been processed — retry with the same Idempotency-Key rather "
|
|
170
|
+
"than issuing a fresh call.",
|
|
171
|
+
None,
|
|
172
|
+
None,
|
|
173
|
+
None,
|
|
174
|
+
) from exc
|
|
175
|
+
request_id = r.headers.get("x-request-id")
|
|
176
|
+
if 200 <= r.status_code < 300:
|
|
177
|
+
if r.status_code == 204:
|
|
178
|
+
return None
|
|
179
|
+
return r.json()
|
|
180
|
+
retryable = attempt < _MAX_RETRIES and (
|
|
181
|
+
r.status_code == 429
|
|
182
|
+
or (r.status_code >= 500 and (idempotent_method or has_idem_key))
|
|
183
|
+
)
|
|
184
|
+
if retryable:
|
|
185
|
+
try:
|
|
186
|
+
retry_after = float(r.headers.get("retry-after", ""))
|
|
187
|
+
except (TypeError, ValueError):
|
|
188
|
+
retry_after = 0.0
|
|
189
|
+
wait = retry_after if retry_after > 0 else min(_RETRY_CAP, _RETRY_BASE * (2 ** attempt))
|
|
190
|
+
time.sleep(wait)
|
|
191
|
+
attempt += 1
|
|
192
|
+
continue
|
|
193
|
+
try:
|
|
194
|
+
payload = r.json()
|
|
195
|
+
except (ValueError, json.JSONDecodeError):
|
|
196
|
+
payload = {}
|
|
197
|
+
err = payload.get("error", {}) if isinstance(payload, dict) else {}
|
|
198
|
+
raise MailsError(
|
|
199
|
+
err.get("type", "api_error"),
|
|
200
|
+
err.get("code", "unknown"),
|
|
201
|
+
err.get("message", f"{method} {path} failed with {r.status_code}"),
|
|
202
|
+
r.status_code,
|
|
203
|
+
request_id,
|
|
204
|
+
err.get("param"),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# --- high-level helpers ---
|
|
208
|
+
|
|
209
|
+
def send(self, agent: str, **kwargs: Any) -> Dict[str, Any]:
|
|
210
|
+
"""POST /api/v1/messages — send via a named agent."""
|
|
211
|
+
idempotency_key = kwargs.pop("idempotency_key", None)
|
|
212
|
+
kwargs["agent"] = agent
|
|
213
|
+
body = {k: v for k, v in kwargs.items() if v is not None}
|
|
214
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
215
|
+
return self._request("POST", "/api/v1/messages", body, headers)
|
|
216
|
+
|
|
217
|
+
def reply(self, message_id: str, **kwargs: Any) -> Dict[str, Any]:
|
|
218
|
+
body = {k: v for k, v in kwargs.items() if v is not None}
|
|
219
|
+
return self._request("POST", f"/api/v1/messages/{message_id}/reply", body)
|
|
220
|
+
|
|
221
|
+
def forward(self, message_id: str, **kwargs: Any) -> Dict[str, Any]:
|
|
222
|
+
body = {k: v for k, v in kwargs.items() if v is not None}
|
|
223
|
+
return self._request("POST", f"/api/v1/messages/{message_id}/forward", body)
|
|
224
|
+
|
|
225
|
+
def list_threads(self, **opts: Any) -> Dict[str, Any]:
|
|
226
|
+
qs = "?" + urlencode({k: v for k, v in opts.items() if v is not None}) if opts else ""
|
|
227
|
+
return self._request("GET", f"/api/v1/threads{qs}")
|
|
228
|
+
|
|
229
|
+
def get_thread(self, thread_id: str) -> Dict[str, Any]:
|
|
230
|
+
return self._request("GET", f"/api/v1/threads/{thread_id}")
|
|
231
|
+
|
|
232
|
+
def list_received(self, **opts: Any) -> Dict[str, Any]:
|
|
233
|
+
qs = "?" + urlencode({k: v for k, v in opts.items() if v is not None}) if opts else ""
|
|
234
|
+
return self._request("GET", f"/api/v1/messages/received{qs}")
|
|
235
|
+
|
|
236
|
+
def get_received(self, id: str) -> Dict[str, Any]:
|
|
237
|
+
return self._request("GET", f"/api/v1/messages/received/{id}")
|
|
238
|
+
|
|
239
|
+
def list_agents(self, **opts: Any) -> Dict[str, Any]:
|
|
240
|
+
qs = "?" + urlencode({k: v for k, v in opts.items() if v is not None}) if opts else ""
|
|
241
|
+
return self._request("GET", f"/api/v1/agents{qs}")
|
|
242
|
+
|
|
243
|
+
def create_agent(
|
|
244
|
+
self,
|
|
245
|
+
name: str,
|
|
246
|
+
domain: Optional[str] = None,
|
|
247
|
+
allowlist_domains: Optional[List[str]] = None,
|
|
248
|
+
) -> Dict[str, Any]:
|
|
249
|
+
body = {"name": name}
|
|
250
|
+
if domain:
|
|
251
|
+
body["domain"] = domain
|
|
252
|
+
if allowlist_domains:
|
|
253
|
+
body["allowlist_domains"] = allowlist_domains
|
|
254
|
+
return self._request("POST", "/api/v1/agents", body)
|
|
255
|
+
|
|
256
|
+
def list_events(self, **opts: Any) -> Dict[str, Any]:
|
|
257
|
+
qs = "?" + urlencode({k: v for k, v in opts.items() if v is not None}) if opts else ""
|
|
258
|
+
return self._request("GET", f"/api/v1/events{qs}")
|
|
259
|
+
|
|
260
|
+
def get_event(self, event_id: str) -> Dict[str, Any]:
|
|
261
|
+
return self._request("GET", f"/api/v1/events/{event_id}")
|
|
262
|
+
|
|
263
|
+
def get_reputation(self, agent_id: Optional[str] = None) -> Dict[str, Any]:
|
|
264
|
+
qs = "?" + urlencode({"agent_id": agent_id}) if agent_id else ""
|
|
265
|
+
return self._request("GET", f"/api/v1/reputation{qs}")
|
|
266
|
+
|
|
267
|
+
def check_suppression(self, address: str) -> Dict[str, Any]:
|
|
268
|
+
return self._request("GET", "/api/v1/suppression?" + urlencode({"address": address}))
|
|
269
|
+
|
|
270
|
+
def allow_address(self, address: str, attestation: str) -> Dict[str, Any]:
|
|
271
|
+
return self._request("POST", "/api/v1/suppression/allow", {"address": address, "attestation": attestation})
|
|
272
|
+
|
|
273
|
+
def create_draft(self, **kwargs: Any) -> Dict[str, Any]:
|
|
274
|
+
body = {k: v for k, v in kwargs.items() if v is not None}
|
|
275
|
+
return self._request("POST", "/api/v1/drafts", body)
|
|
276
|
+
|
|
277
|
+
def send_draft(self, draft_id: str, send_at: Optional[str] = None) -> Dict[str, Any]:
|
|
278
|
+
body: Dict[str, Any] = {}
|
|
279
|
+
if send_at is not None:
|
|
280
|
+
body["send_at"] = send_at
|
|
281
|
+
return self._request("POST", f"/api/v1/drafts/{draft_id}/send", body)
|
|
282
|
+
|
|
283
|
+
def usage(self) -> Dict[str, Any]:
|
|
284
|
+
return self._request("GET", "/api/v1/billing/usage")
|
|
285
|
+
|
|
286
|
+
def stream_events(
|
|
287
|
+
self,
|
|
288
|
+
event_types: Optional[List[str]] = None,
|
|
289
|
+
since: Optional[str] = None,
|
|
290
|
+
on_event: Optional[Callable[[Dict[str, Any]], None]] = None,
|
|
291
|
+
on_error: Optional[Callable[[Exception], None]] = None,
|
|
292
|
+
stop_event: Optional[threading.Event] = None,
|
|
293
|
+
) -> None:
|
|
294
|
+
"""Blocking SSE consumer. Pass stop_event to break the loop from another thread."""
|
|
295
|
+
httpx_mod = _require_httpx()
|
|
296
|
+
params: Dict[str, str] = {}
|
|
297
|
+
if event_types:
|
|
298
|
+
params["event_types"] = ",".join(event_types)
|
|
299
|
+
if since:
|
|
300
|
+
params["since"] = since
|
|
301
|
+
qs = ("?" + urlencode(params)) if params else ""
|
|
302
|
+
url = f"{self.base_url}/api/v1/events/stream{qs}"
|
|
303
|
+
headers = {
|
|
304
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
305
|
+
"Accept": "text/event-stream",
|
|
306
|
+
"User-Agent": f"mailsai-python/{__version__}",
|
|
307
|
+
}
|
|
308
|
+
try:
|
|
309
|
+
with httpx_mod.stream("GET", url, headers=headers, timeout=None) as r:
|
|
310
|
+
r.raise_for_status()
|
|
311
|
+
for line in r.iter_lines():
|
|
312
|
+
if stop_event is not None and stop_event.is_set():
|
|
313
|
+
return
|
|
314
|
+
if not line or not line.startswith("data: "):
|
|
315
|
+
continue
|
|
316
|
+
try:
|
|
317
|
+
evt = json.loads(line[6:])
|
|
318
|
+
except json.JSONDecodeError:
|
|
319
|
+
continue
|
|
320
|
+
if on_event is not None:
|
|
321
|
+
on_event(evt)
|
|
322
|
+
except Exception as e: # noqa: BLE001
|
|
323
|
+
if on_error is not None:
|
|
324
|
+
on_error(e)
|
|
325
|
+
else:
|
|
326
|
+
raise
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class Agent:
|
|
330
|
+
"""A named agent. Pass an existing Client or omit to auto-construct from env."""
|
|
331
|
+
|
|
332
|
+
def __init__(self, client: Client, name: str, domain: Optional[str] = None):
|
|
333
|
+
self._client = client
|
|
334
|
+
self.name = name
|
|
335
|
+
self._domain = domain
|
|
336
|
+
self._reply_handlers: List[Callable[[Dict[str, Any]], None]] = []
|
|
337
|
+
self._id: Optional[str] = None
|
|
338
|
+
self._stop_event: Optional[threading.Event] = None
|
|
339
|
+
|
|
340
|
+
@property
|
|
341
|
+
def client(self) -> Client:
|
|
342
|
+
return self._client
|
|
343
|
+
|
|
344
|
+
def send(
|
|
345
|
+
self,
|
|
346
|
+
to: Union[str, List[str]],
|
|
347
|
+
subject: str,
|
|
348
|
+
body: Optional[str] = None,
|
|
349
|
+
body_text: Optional[str] = None,
|
|
350
|
+
body_html: Optional[str] = None,
|
|
351
|
+
cc: Optional[List[str]] = None,
|
|
352
|
+
bcc: Optional[List[str]] = None,
|
|
353
|
+
reply_to: Optional[str] = None,
|
|
354
|
+
in_reply_to_message_id: Optional[str] = None,
|
|
355
|
+
scheduled_at: Optional[str] = None,
|
|
356
|
+
tags: Optional[List[Dict[str, str]]] = None,
|
|
357
|
+
metadata: Optional[Dict[str, str]] = None,
|
|
358
|
+
pool_hint: Optional[str] = None,
|
|
359
|
+
idempotency_key: Optional[str] = None,
|
|
360
|
+
) -> Dict[str, Any]:
|
|
361
|
+
return self._client.send(
|
|
362
|
+
self.name,
|
|
363
|
+
to=to,
|
|
364
|
+
subject=subject,
|
|
365
|
+
body_text=body_text or body,
|
|
366
|
+
body_html=body_html,
|
|
367
|
+
cc=cc,
|
|
368
|
+
bcc=bcc,
|
|
369
|
+
reply_to=reply_to,
|
|
370
|
+
in_reply_to_message_id=in_reply_to_message_id,
|
|
371
|
+
scheduled_at=scheduled_at,
|
|
372
|
+
tags=tags,
|
|
373
|
+
metadata=metadata,
|
|
374
|
+
pool_hint=pool_hint,
|
|
375
|
+
idempotency_key=idempotency_key,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
def on_reply(self, handler: Callable[[Dict[str, Any]], None]) -> Callable[[Dict[str, Any]], None]:
|
|
379
|
+
"""Decorator. Register a callback for reply.received events for this agent.
|
|
380
|
+
|
|
381
|
+
Call agent.start_listening() to begin SSE consumption.
|
|
382
|
+
"""
|
|
383
|
+
self._reply_handlers.append(handler)
|
|
384
|
+
return handler
|
|
385
|
+
|
|
386
|
+
def list_replies(self, limit: int = 10, since: Optional[str] = None) -> Dict[str, Any]:
|
|
387
|
+
params: Dict[str, Any] = {"event_type": "reply.received", "agent_id": self.name, "limit": limit}
|
|
388
|
+
if since is not None:
|
|
389
|
+
params["since"] = since
|
|
390
|
+
return self._client.list_events(**params)
|
|
391
|
+
|
|
392
|
+
def reputation(self) -> Dict[str, Any]:
|
|
393
|
+
return self._client.get_reputation(agent_id=self.name)
|
|
394
|
+
|
|
395
|
+
def _resolve_id(self) -> str:
|
|
396
|
+
if self._id is not None:
|
|
397
|
+
return self._id
|
|
398
|
+
rows = self._client.list_agents(limit=100)
|
|
399
|
+
for row in rows.get("data", []):
|
|
400
|
+
if row.get("name") == self.name:
|
|
401
|
+
self._id = row["id"]
|
|
402
|
+
return self._id
|
|
403
|
+
raise MailsError(
|
|
404
|
+
"resource_error",
|
|
405
|
+
"agent_not_found",
|
|
406
|
+
f'Agent "{self.name}" not found. Create it with client.create_agent("{self.name}") first.',
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
def start_listening(self, blocking: bool = True) -> Optional[threading.Thread]:
|
|
410
|
+
"""Begin consuming reply.received events and dispatch to @on_reply handlers.
|
|
411
|
+
|
|
412
|
+
blocking=True (default) blocks the calling thread. blocking=False spawns a
|
|
413
|
+
daemon thread; returns the thread for caller to join().
|
|
414
|
+
"""
|
|
415
|
+
if not self._reply_handlers:
|
|
416
|
+
raise RuntimeError("No reply handlers registered. Use @agent.on_reply first.")
|
|
417
|
+
if self._stop_event is None:
|
|
418
|
+
self._stop_event = threading.Event()
|
|
419
|
+
|
|
420
|
+
def _loop() -> None:
|
|
421
|
+
try:
|
|
422
|
+
agent_id = self._resolve_id()
|
|
423
|
+
except Exception as e: # noqa: BLE001
|
|
424
|
+
for h in self._reply_handlers:
|
|
425
|
+
print(f"[mailsai] resolve_id failed: {e}", file=sys.stderr)
|
|
426
|
+
return
|
|
427
|
+
|
|
428
|
+
def _on_event(evt: Dict[str, Any]) -> None:
|
|
429
|
+
if evt.get("agent_id") != agent_id:
|
|
430
|
+
return
|
|
431
|
+
for h in self._reply_handlers:
|
|
432
|
+
try:
|
|
433
|
+
h(evt)
|
|
434
|
+
except Exception as exc: # noqa: BLE001
|
|
435
|
+
print(f"[mailsai] handler error: {exc}", file=sys.stderr)
|
|
436
|
+
|
|
437
|
+
def _on_error(e: Exception) -> None:
|
|
438
|
+
print(f"[mailsai] stream error: {e}", file=sys.stderr)
|
|
439
|
+
|
|
440
|
+
self._client.stream_events(
|
|
441
|
+
event_types=["reply.received"],
|
|
442
|
+
on_event=_on_event,
|
|
443
|
+
on_error=_on_error,
|
|
444
|
+
stop_event=self._stop_event,
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
if blocking:
|
|
448
|
+
_loop()
|
|
449
|
+
return None
|
|
450
|
+
t = threading.Thread(target=_loop, daemon=True, name=f"mailsai-stream-{self.name}")
|
|
451
|
+
t.start()
|
|
452
|
+
return t
|
|
453
|
+
|
|
454
|
+
def stop_listening(self) -> None:
|
|
455
|
+
if self._stop_event is not None:
|
|
456
|
+
self._stop_event.set()
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def create_client(api_key: Optional[str] = None, base_url: Optional[str] = None) -> Client:
|
|
460
|
+
"""Return a low-level Client. Use this when you need full API access."""
|
|
461
|
+
return Client(api_key=api_key, base_url=base_url)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def agent(
|
|
465
|
+
name: str,
|
|
466
|
+
domain: Optional[str] = None,
|
|
467
|
+
api_key: Optional[str] = None,
|
|
468
|
+
base_url: Optional[str] = None,
|
|
469
|
+
) -> Agent:
|
|
470
|
+
"""Create an Agent. Reuses one Client per call; pass api_key or set MAILS_API_KEY."""
|
|
471
|
+
return Agent(Client(api_key=api_key, base_url=base_url), name, domain)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def verify_webhook(
|
|
475
|
+
body: str,
|
|
476
|
+
signature: str,
|
|
477
|
+
secret: str,
|
|
478
|
+
tolerance_sec: int = 300,
|
|
479
|
+
) -> Optional[Dict[str, Any]]:
|
|
480
|
+
"""Verify a webhook signature header (Stripe-style t=<ts>,v1=<hex>).
|
|
481
|
+
|
|
482
|
+
Returns the parsed event dict if valid, None if invalid (expired, bad MAC,
|
|
483
|
+
or malformed). Use this in your webhook handler before trusting the body.
|
|
484
|
+
"""
|
|
485
|
+
try:
|
|
486
|
+
parts = dict(p.split("=", 1) for p in signature.split(",") if "=" in p)
|
|
487
|
+
ts = int(parts.get("t", "0"))
|
|
488
|
+
if abs(int(time.time()) - ts) > tolerance_sec:
|
|
489
|
+
return None
|
|
490
|
+
expected = hmac.new(
|
|
491
|
+
secret.encode(),
|
|
492
|
+
f"{ts}.{body}".encode(),
|
|
493
|
+
hashlib.sha256,
|
|
494
|
+
).hexdigest()
|
|
495
|
+
if not hmac.compare_digest(expected, parts.get("v1", "")):
|
|
496
|
+
return None
|
|
497
|
+
return json.loads(body)
|
|
498
|
+
except (ValueError, KeyError, json.JSONDecodeError):
|
|
499
|
+
return None
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mailsai
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Email API for AI agents — Python SDK. Per-agent email addresses on your domain, per-agent reputation, and prompt-injection scanning on every inbound. Send, receive structured inbound replies, and identify agents via REST. Intent + entity extraction is opt-in.
|
|
5
|
+
Author-email: "Mails.ai" <support@mails.ai>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://mails.ai
|
|
8
|
+
Project-URL: Documentation, https://mails.ai/docs
|
|
9
|
+
Project-URL: Source, https://github.com/RolloutsAI/mails-api
|
|
10
|
+
Project-URL: Issues, https://github.com/RolloutsAI/mails-api/issues
|
|
11
|
+
Keywords: email,ai,agent,mcp,mails.ai,agent-identity,sender-reputation,agent-email,prompt-injection,transactional-email
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Communications :: Email
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: httpx>=0.24.0
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# mailsai
|
|
30
|
+
|
|
31
|
+
Email API for AI agents — Python SDK.
|
|
32
|
+
|
|
33
|
+
Per-agent email addresses on your domain, per-agent reputation, and prompt-injection scanning on every inbound. Your agent reads the reply and decides what to say — intent + entity extraction is opt-in.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install mailsai
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
40
|
+
|
|
41
|
+
Create the agent once (this mints its inbox address), then send from it. Skipping the
|
|
42
|
+
create step is the most common first-run error — `agent("sarah")` only *references* an
|
|
43
|
+
agent, it does not create one, so sending returns `404 agent_not_found`.
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from mailsai import Client, agent
|
|
47
|
+
|
|
48
|
+
# Reads MAILS_API_KEY from env (or pass api_key=... explicitly)
|
|
49
|
+
client = Client()
|
|
50
|
+
|
|
51
|
+
# One-time setup: create the agent. It gets sarah@<your-workspace>.mails.ai
|
|
52
|
+
client.create_agent("sarah")
|
|
53
|
+
|
|
54
|
+
sarah = agent("sarah")
|
|
55
|
+
|
|
56
|
+
# mails.ai is transactional-only: mail the recipient asked for. Cold outreach and bulk
|
|
57
|
+
# marketing are refused with 422 cold_email_prohibited — that is deliberate, and it is
|
|
58
|
+
# what keeps the sending reputation clean for everyone on the platform.
|
|
59
|
+
sarah.send(
|
|
60
|
+
to="you@example.com",
|
|
61
|
+
subject="Your verification code is 481902",
|
|
62
|
+
body="Your verification code is 481902. It expires in 15 minutes.",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
@sarah.on_reply
|
|
66
|
+
def handle(reply):
|
|
67
|
+
# Always present — the delivery + security + identity layer:
|
|
68
|
+
# reply["injection_score"] -> 0.02 # six-category prompt-injection scan
|
|
69
|
+
# reply["sender_reputation"] -> 0.91 # per-agent reputation
|
|
70
|
+
#
|
|
71
|
+
# Present only when classification is enabled (opt-in, +$0.003/inbound):
|
|
72
|
+
# reply["intent"] -> "schedule_demo" | "ask_question" | …
|
|
73
|
+
# reply["entities"] -> {"date": "...", "time": "..."}
|
|
74
|
+
# reply["urgency"] -> 0.8
|
|
75
|
+
#
|
|
76
|
+
# Your agent reads the reply and decides what to send next.
|
|
77
|
+
print(reply["injection_score"], reply["sender_reputation"])
|
|
78
|
+
|
|
79
|
+
sarah.start_listening() # blocks; opens SSE stream and dispatches replies
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
For non-blocking, pass `blocking=False` to `start_listening()` — it returns a daemon thread.
|
|
83
|
+
|
|
84
|
+
## Lower-level client
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from mailsai import create_client
|
|
88
|
+
|
|
89
|
+
c = create_client() # reads MAILS_API_KEY
|
|
90
|
+
|
|
91
|
+
# Send via any agent
|
|
92
|
+
c.send("sarah", to="lead@example.com", subject="Demo", body_text="…")
|
|
93
|
+
|
|
94
|
+
# Resources
|
|
95
|
+
threads = c.list_threads(agent_id="agent_abc123")
|
|
96
|
+
usage = c.usage()
|
|
97
|
+
rep = c.get_reputation(agent_id="agent_abc123")
|
|
98
|
+
|
|
99
|
+
# Drafts
|
|
100
|
+
draft = c.create_draft(agent="sarah", to="lead@example.com", subject="Demo", body_text="…")
|
|
101
|
+
c.send_draft(draft["id"])
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Webhook verification
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from mailsai import verify_webhook
|
|
108
|
+
|
|
109
|
+
# In your webhook handler:
|
|
110
|
+
event = verify_webhook(
|
|
111
|
+
body=request.body.decode(),
|
|
112
|
+
signature=request.headers["X-Mails-Signature"],
|
|
113
|
+
secret=os.environ["MAILS_WEBHOOK_SECRET"],
|
|
114
|
+
)
|
|
115
|
+
if event is None:
|
|
116
|
+
return Response(status=400)
|
|
117
|
+
# event is the verified inbound event dict
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Errors
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from mailsai import MailsError
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
sarah.send(to="blocked@example.com", subject="…", body="…")
|
|
127
|
+
except MailsError as e:
|
|
128
|
+
print(e.type, e.code, e.message, e.status, e.request_id)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Configuration
|
|
132
|
+
|
|
133
|
+
| Param / env var | Default |
|
|
134
|
+
|---|---|
|
|
135
|
+
| `api_key` / `MAILS_API_KEY` | (required) |
|
|
136
|
+
| `base_url` / `MAILS_BASE_URL` | `https://api.mails.ai` |
|
|
137
|
+
|
|
138
|
+
## Documentation
|
|
139
|
+
|
|
140
|
+
- Full docs: [mails.ai/docs](https://mails.ai/docs)
|
|
141
|
+
- Source: [github.com/RolloutsAI/mails-api](https://github.com/RolloutsAI/mails-api/tree/main/packages/sdk-py)
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
httpx>=0.24.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mailsai
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mailsai"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Email API for AI agents — Python SDK. Per-agent email addresses on your domain, per-agent reputation, and prompt-injection scanning on every inbound. Send, receive structured inbound replies, and identify agents via REST. Intent + entity extraction is opt-in."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Mails.ai", email = "support@mails.ai" }]
|
|
13
|
+
keywords = ["email", "ai", "agent", "mcp", "mails.ai", "agent-identity", "sender-reputation", "agent-email", "prompt-injection", "transactional-email"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Communications :: Email",
|
|
25
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
26
|
+
]
|
|
27
|
+
dependencies = ["httpx>=0.24.0"]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://mails.ai"
|
|
31
|
+
Documentation = "https://mails.ai/docs"
|
|
32
|
+
Source = "https://github.com/RolloutsAI/mails-api"
|
|
33
|
+
Issues = "https://github.com/RolloutsAI/mails-api/issues"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools]
|
|
36
|
+
packages = ["mailsai"]
|
mailsai-0.1.1/setup.cfg
ADDED