mailsai 0.2.4__tar.gz → 0.3.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.
- mailsai-0.3.0/PKG-INFO +422 -0
- mailsai-0.3.0/README.md +394 -0
- mailsai-0.3.0/mailsai/__init__.py +1420 -0
- mailsai-0.3.0/mailsai.egg-info/PKG-INFO +422 -0
- {mailsai-0.2.4 → mailsai-0.3.0}/pyproject.toml +2 -2
- mailsai-0.2.4/PKG-INFO +0 -164
- mailsai-0.2.4/README.md +0 -136
- mailsai-0.2.4/mailsai/__init__.py +0 -555
- mailsai-0.2.4/mailsai.egg-info/PKG-INFO +0 -164
- {mailsai-0.2.4 → mailsai-0.3.0}/LICENSE +0 -0
- {mailsai-0.2.4 → mailsai-0.3.0}/mailsai.egg-info/SOURCES.txt +0 -0
- {mailsai-0.2.4 → mailsai-0.3.0}/mailsai.egg-info/dependency_links.txt +0 -0
- {mailsai-0.2.4 → mailsai-0.3.0}/mailsai.egg-info/requires.txt +0 -0
- {mailsai-0.2.4 → mailsai-0.3.0}/mailsai.egg-info/top_level.txt +0 -0
- {mailsai-0.2.4 → mailsai-0.3.0}/setup.cfg +0 -0
mailsai-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mailsai
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Email API for AI agents — Python SDK. Per-agent email addresses on your domain, per-agent reputation, and prompt-injection scanning on inbound mail. 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/mailsai/mailsai
|
|
10
|
+
Project-URL: Issues, https://github.com/mailsai/mailsai/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 inbound mail. 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
|
+
No domain to verify, no DNS. Signup gave your workspace an agent called `hello`; `from` picks
|
|
42
|
+
the sender, and on a paid plan a new name creates another agent on first use.
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from mailsai import Client
|
|
46
|
+
|
|
47
|
+
# Reads MAILS_API_KEY from env (or pass api_key=... explicitly)
|
|
48
|
+
client = Client()
|
|
49
|
+
|
|
50
|
+
# mails.ai is transactional-only: mail the recipient asked for. Cold outreach and bulk
|
|
51
|
+
# marketing are refused with 422 cold_email_prohibited — that is deliberate, and it is
|
|
52
|
+
# what keeps the sending reputation clean for everyone on the platform.
|
|
53
|
+
sent = client.send(**{
|
|
54
|
+
"from": "hello",
|
|
55
|
+
"to": "you@example.com",
|
|
56
|
+
"subject": "Your verification code is 481902",
|
|
57
|
+
"body": "Your verification code is 481902. It expires in 15 minutes.",
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
print(sent["from"]) # hello@<your-workspace>.mails.ai — which identity actually sent
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
A **test key** (`mk_test_…`) runs that entire path — validation, the firewall, threading,
|
|
64
|
+
events, webhooks — and delivers nothing, so you can integrate before you decide anything. On a
|
|
65
|
+
test key, check `sent.get("classifier_warning")`: it is how a sandbox send tells you the same
|
|
66
|
+
message would be refused on a live key.
|
|
67
|
+
|
|
68
|
+
Already have an agent, or want to name it explicitly? Both of these still work:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
client.create_agent("sarah") # explicit creation, when you want it
|
|
72
|
+
client.send("sarah", to="…", subject="…", body="…")
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Note the free plan allows **one** agent. If `from` names a second one and there is no room,
|
|
76
|
+
the send is refused with `402 plan_limit_exceeded` naming the agent you do have — it is never
|
|
77
|
+
quietly sent under a different identity.
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from mailsai import agent
|
|
81
|
+
|
|
82
|
+
sarah = agent("sarah")
|
|
83
|
+
|
|
84
|
+
@sarah.on_reply
|
|
85
|
+
def handle(reply):
|
|
86
|
+
# A reply is scanned for prompt injection:
|
|
87
|
+
# reply["injection_score"] -> 0.02 # 0 to 1; at 0.95 or more it is marked quarantined
|
|
88
|
+
# reply["quarantined"] -> False
|
|
89
|
+
#
|
|
90
|
+
# A reply is not read for intent: that is for new mail (the message.received events
|
|
91
|
+
# stream_events delivers) to an agent with classify_inbound on (Pro and Scale include
|
|
92
|
+
# it at no extra charge). So on every reply:
|
|
93
|
+
# reply["intent"] -> "general_reply"
|
|
94
|
+
# reply["sender_reputation"] -> 0.85 # 0.3 when the reply scores as an injection
|
|
95
|
+
# # attempt, 0.5 when it could not be scanned
|
|
96
|
+
#
|
|
97
|
+
# Your agent reads the reply and decides what to send next.
|
|
98
|
+
print(reply["injection_score"], reply["quarantined"])
|
|
99
|
+
|
|
100
|
+
sarah.start_listening() # blocks; opens SSE stream and dispatches replies
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
For non-blocking, pass `blocking=False` to `start_listening()` — it returns a daemon thread.
|
|
104
|
+
|
|
105
|
+
## Events
|
|
106
|
+
|
|
107
|
+
`list_events()`, `stream_events()` and webhooks carry these types. Inbound: `reply.received`,
|
|
108
|
+
`message.received`, `message.received.unauthenticated` (a forged sender: no DKIM signature aligned
|
|
109
|
+
with the From domain verifies, and that domain's DMARC policy is quarantine or reject) and
|
|
110
|
+
`message.received.over_limit` (mail received while the workspace was over a limit; it carries no
|
|
111
|
+
subject or body). Outbound: `message.sent`, `message.scheduled`, `message.delivered`,
|
|
112
|
+
`message.delayed` (a transient bounce), `message.bounced`, `message.complained` and
|
|
113
|
+
`message.failed` (a scheduled send that ended without going out). Drafts: `draft.scheduled`,
|
|
114
|
+
`draft.sending`, `draft.sent`, `draft.failed`. Also `suppression.added`, `webhook.test`,
|
|
115
|
+
`thread.created`, `thread.updated` and `thread.deleted`. A `"*"` webhook gets the thread events
|
|
116
|
+
and `message.delayed` only when it names them.
|
|
117
|
+
|
|
118
|
+
An inbound event's `data` says how the sender was authenticated, checked from the email itself and
|
|
119
|
+
never from a header in it: `dkim` (`pass`, `fail`, `none` for an unsigned email, or `unknown`),
|
|
120
|
+
`dkim_domain` (the domain of the passing signature that matches From, or None), `dmarc` (`pass`,
|
|
121
|
+
`none`, or `unknown`; `fail` is not reported while SPF cannot be checked), `spf` (`unknown` for
|
|
122
|
+
now) and `auth_failed` (True only on `message.received.unauthenticated`). It also says who the
|
|
123
|
+
email is from. `from` is a dict with `address` and `name`, the first mailbox of the From header:
|
|
124
|
+
the address those checks are about, and the one a reply goes to when there is no Reply-To.
|
|
125
|
+
`envelope_from` is the envelope sender, where bounces go, often a sending service's own address.
|
|
126
|
+
`arc` is the email's ARC chain as we checked it, and `trusted_forwarder` names the mail service
|
|
127
|
+
whose ARC seal vouched for the original sender of forwarded mail, or None.
|
|
128
|
+
|
|
129
|
+
## Lower-level client
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from mailsai import create_client
|
|
133
|
+
|
|
134
|
+
c = create_client() # reads MAILS_API_KEY
|
|
135
|
+
|
|
136
|
+
# Send via any agent
|
|
137
|
+
c.send("sarah", to="user@example.com", subject="Your receipt", body_text="…")
|
|
138
|
+
|
|
139
|
+
# Resources
|
|
140
|
+
threads = c.list_threads(agent_id="sarah") # an agent's name or id (agt_…)
|
|
141
|
+
usage = c.usage()
|
|
142
|
+
rep = c.get_reputation(agent_id="sarah")
|
|
143
|
+
|
|
144
|
+
# Drafts
|
|
145
|
+
draft = c.create_draft(agent="sarah", to="user@example.com", subject="Your receipt", body_text="…")
|
|
146
|
+
c.send_draft(draft["id"])
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Search, labels and attachments:
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
from datetime import datetime, timezone
|
|
153
|
+
|
|
154
|
+
hits = c.search("invoice", type="received", has_attachment=True, after=datetime(2026, 10, 1, tzinfo=timezone.utc))
|
|
155
|
+
|
|
156
|
+
vip = c.create_label("VIP", color="red")
|
|
157
|
+
c.update_thread(hits["data"][0]["thread_id"], label_ids=[vip["id"]])
|
|
158
|
+
|
|
159
|
+
files = c.list_attachments(hits["data"][0]["id"])
|
|
160
|
+
data = c.download_attachment(hits["data"][0]["id"], files["data"][0]["id"]) # bytes
|
|
161
|
+
with open(files["data"][0]["filename"], "wb") as f:
|
|
162
|
+
f.write(data)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Booleans go to the API as `true`/`false` (`c.list_threads(read=False)`), and `from`, a Python
|
|
166
|
+
keyword, is written `from_` in `search`, `get_metrics` and `test_inbound`.
|
|
167
|
+
|
|
168
|
+
`list_events(since=…)` (and `agent.list_replies(since=…)`) takes a `datetime` or an ISO 8601
|
|
169
|
+
string and sends it in the form every `created_at` has (`2026-10-04T09:30:00.000Z`); a naive
|
|
170
|
+
datetime, or a string with no offset, is UTC. A plain date such as `2026-10-04` is sent as it
|
|
171
|
+
is. A value that is not a time raises `MailsError` (`param == "since"`) before any request is
|
|
172
|
+
made, as the API would answer it 400. `stream_events(since=…)` is different: it takes
|
|
173
|
+
a position the stream gave, or an event id (`evt_…`), to resume from.
|
|
174
|
+
|
|
175
|
+
`stream_events()` reconnects by itself, from where the server said the stream was, and does not
|
|
176
|
+
hand `on_event` the same event twice while it runs. Events arrive in id order, except one that was
|
|
177
|
+
written late: it arrives after newer ones instead of being skipped. To carry on after a restart,
|
|
178
|
+
save the position `on_position` gives you and pass it as `since`: an event id starts after that
|
|
179
|
+
event, and a position starts exactly where the stream was. Across a restart delivery is at least
|
|
180
|
+
once, so keep the event ids you handled if handling one twice would matter.
|
|
181
|
+
|
|
182
|
+
## Every method
|
|
183
|
+
|
|
184
|
+
All of these are on `Client` (from `Client()` or `create_client()`), and each returns the API's
|
|
185
|
+
JSON as a dict unless the table says otherwise.
|
|
186
|
+
|
|
187
|
+
**Messages you send**
|
|
188
|
+
|
|
189
|
+
| Method | API call |
|
|
190
|
+
|---|---|
|
|
191
|
+
| `send(agent=None, **fields)` | `POST /v1/messages` |
|
|
192
|
+
| `batch_send(messages, idempotency_key=None)` | `POST /v1/messages/batch` |
|
|
193
|
+
| `list_messages(limit, cursor, agent_id, thread_id, q)` | `GET /v1/messages` |
|
|
194
|
+
| `get_message(message_id)` | `GET /v1/messages/{id}` |
|
|
195
|
+
| `reply(message_id, body=…, idempotency_key=None, **fields)` | `POST /v1/messages/{id}/reply` |
|
|
196
|
+
| `forward(message_id, to=…, idempotency_key=None, **fields)` | `POST /v1/messages/{id}/forward` |
|
|
197
|
+
| `reschedule_message(message_id, scheduled_at)` | `PATCH /v1/messages/{id}` |
|
|
198
|
+
| `cancel_message(message_id)` | `POST /v1/messages/{id}/cancel` |
|
|
199
|
+
| `get_raw_message(message_id)`, the message as an RFC 822 `str` | `GET /v1/messages/{id}/raw` |
|
|
200
|
+
| `appeal(agent=None, subject=…, body=…)` | `POST /v1/messages/appeal` |
|
|
201
|
+
|
|
202
|
+
**Mail you receive**
|
|
203
|
+
|
|
204
|
+
| Method | API call |
|
|
205
|
+
|---|---|
|
|
206
|
+
| `list_received(limit, cursor, agent_id, thread_id, q)` | `GET /v1/messages/received` |
|
|
207
|
+
| `get_received(id)` | `GET /v1/messages/received/{id}` |
|
|
208
|
+
| `list_attachments(message_id)` | `GET /v1/messages/received/{id}/attachments` |
|
|
209
|
+
| `download_attachment(message_id, attachment_id, inline=False)`, the file as `bytes` | `GET /v1/messages/received/{id}/attachments/{attachment_id}` |
|
|
210
|
+
| `test_inbound(agent, from_=…, body_text=…, **fields)`, test keys only | `POST /v1/test/inbound` |
|
|
211
|
+
|
|
212
|
+
**Threads, labels and search**
|
|
213
|
+
|
|
214
|
+
| Method | API call |
|
|
215
|
+
|---|---|
|
|
216
|
+
| `list_threads(limit, cursor, agent_id, status, folder, read, label, q)` | `GET /v1/threads` |
|
|
217
|
+
| `get_thread(thread_id)`, with its messages | `GET /v1/threads/{id}` |
|
|
218
|
+
| `update_thread(thread_id, labels=…, label_ids=…, status=…, read=…, folder=…)` | `PATCH /v1/threads/{id}` |
|
|
219
|
+
| `delete_thread(thread_id, permanent=False)`, to trash or for good | `DELETE /v1/threads/{id}` |
|
|
220
|
+
| `list_labels()` | `GET /v1/labels` |
|
|
221
|
+
| `create_label(name, color=None)` | `POST /v1/labels` |
|
|
222
|
+
| `get_label(label_id)` | `GET /v1/labels/{id}` |
|
|
223
|
+
| `update_label(label_id, name=None, color=None)` | `PATCH /v1/labels/{id}` |
|
|
224
|
+
| `delete_label(label_id)` | `DELETE /v1/labels/{id}` |
|
|
225
|
+
| `search(q=None, agent_id, from_, to, after, before, has_attachment, folder, label, type, limit, cursor)` | `GET /v1/search` |
|
|
226
|
+
|
|
227
|
+
To restore a trashed thread, move it back: `update_thread(thread_id, folder="inbox")`.
|
|
228
|
+
|
|
229
|
+
**Drafts**
|
|
230
|
+
|
|
231
|
+
| Method | API call |
|
|
232
|
+
|---|---|
|
|
233
|
+
| `create_draft(**fields)` | `POST /v1/drafts` |
|
|
234
|
+
| `list_drafts(limit, cursor, agent_id, status)` | `GET /v1/drafts` |
|
|
235
|
+
| `get_draft(draft_id)` | `GET /v1/drafts/{id}` |
|
|
236
|
+
| `update_draft(draft_id, **fields)` | `PATCH /v1/drafts/{id}` |
|
|
237
|
+
| `delete_draft(draft_id)` | `DELETE /v1/drafts/{id}` |
|
|
238
|
+
| `send_draft(draft_id, send_at=None, idempotency_key=None)` | `POST /v1/drafts/{id}/send` |
|
|
239
|
+
|
|
240
|
+
**Agents**
|
|
241
|
+
|
|
242
|
+
| Method | API call |
|
|
243
|
+
|---|---|
|
|
244
|
+
| `create_agent(name, domain=None, allowlist_domains=None, …)`, or `create_agent(address=…, forwarding=True)` for an address on your own verified domain | `POST /v1/agents` |
|
|
245
|
+
| `list_agents(limit, cursor, status)` | `GET /v1/agents` |
|
|
246
|
+
| `get_agent(agent_id)` | `GET /v1/agents/{id}` |
|
|
247
|
+
| `update_agent(agent_id, **fields)` | `PATCH /v1/agents/{id}` |
|
|
248
|
+
| `archive_agent(agent_id)` | `DELETE /v1/agents/{id}` |
|
|
249
|
+
| `get_agent_inbox(agent_id)`, unread and thread counts per folder and label | `GET /v1/agents/{id}/inbox` |
|
|
250
|
+
| `verify_agent_forwarding(agent_id)`, one test email through a forwarding-mode agent's forward | `POST /v1/agents/{id}/forwarding/verify` |
|
|
251
|
+
|
|
252
|
+
**Events and webhooks**
|
|
253
|
+
|
|
254
|
+
| Method | API call |
|
|
255
|
+
|---|---|
|
|
256
|
+
| `list_events(limit, cursor, event_type, agent_id, since)` | `GET /v1/events` |
|
|
257
|
+
| `get_event(event_id)` | `GET /v1/events/{id}` |
|
|
258
|
+
| `redeliver_event(event_id)` | `POST /v1/events/{id}/redeliver` |
|
|
259
|
+
| `stream_events(event_types=None, since=None, on_event=…, on_error=…, stop_event=None, on_position=None)`, blocking | `GET /v1/events/stream` |
|
|
260
|
+
| `create_webhook(url, event_types=None, description=None)` | `POST /v1/webhooks` |
|
|
261
|
+
| `list_webhooks(limit, cursor)` | `GET /v1/webhooks` |
|
|
262
|
+
| `get_webhook(webhook_id)` | `GET /v1/webhooks/{id}` |
|
|
263
|
+
| `update_webhook(webhook_id, **fields)` | `PATCH /v1/webhooks/{id}` |
|
|
264
|
+
| `delete_webhook(webhook_id)` | `DELETE /v1/webhooks/{id}` |
|
|
265
|
+
| `rotate_webhook_secret(webhook_id)` | `POST /v1/webhooks/{id}/rotate-secret` |
|
|
266
|
+
| `list_webhook_deliveries(webhook_id, limit, cursor)` | `GET /v1/webhooks/{id}/deliveries` |
|
|
267
|
+
| `test_webhook(webhook_id)` | `POST /v1/webhooks/{id}/test` |
|
|
268
|
+
| `replay_webhook_delivery(delivery_id)` | `POST /v1/webhook-deliveries/{id}/replay` |
|
|
269
|
+
| `verify_webhook(body, signature, secret)`, a module function; no request | none |
|
|
270
|
+
|
|
271
|
+
**Domains, keys and suppression**
|
|
272
|
+
|
|
273
|
+
| Method | API call |
|
|
274
|
+
|---|---|
|
|
275
|
+
| `create_domain(domain)` | `POST /v1/domains` |
|
|
276
|
+
| `list_domains()` | `GET /v1/domains` |
|
|
277
|
+
| `get_domain(domain_id)` | `GET /v1/domains/{id}` |
|
|
278
|
+
| `verify_domain(domain_id)` | `POST /v1/domains/{id}/verify` |
|
|
279
|
+
| `delete_domain(domain_id)` | `DELETE /v1/domains/{id}` |
|
|
280
|
+
| `create_api_key(name=None, agent_id=None, scopes=None, mode=None, expires_at=None)` | `POST /v1/api-keys` |
|
|
281
|
+
| `list_api_keys(limit, cursor)` | `GET /v1/api-keys` |
|
|
282
|
+
| `revoke_api_key(key_id)` | `DELETE /v1/api-keys/{id}` |
|
|
283
|
+
| `list_connected_apps(limit, cursor)` | `GET /v1/oauth/grants` |
|
|
284
|
+
| `revoke_connected_app(app_id)` | `DELETE /v1/oauth/grants/{id}` |
|
|
285
|
+
| `check_suppression(address)` | `GET /v1/suppression?address=…` |
|
|
286
|
+
| `list_suppression(view=None, limit, cursor)`, `view="suppressed"` for suppressed recipients | `GET /v1/suppression` |
|
|
287
|
+
| `allow_address(address, attestation)` | `POST /v1/suppression/allow` |
|
|
288
|
+
| `revoke_allow(entry_id)` | `DELETE /v1/suppression/allow/{id}` |
|
|
289
|
+
|
|
290
|
+
**Account**
|
|
291
|
+
|
|
292
|
+
| Method | API call |
|
|
293
|
+
|---|---|
|
|
294
|
+
| `me()` | `GET /v1/me` |
|
|
295
|
+
| `get_metrics(from_, to, agent_id, interval)` | `GET /v1/metrics` |
|
|
296
|
+
| `get_reputation(agent_id=None)` | `GET /v1/reputation` |
|
|
297
|
+
| `usage()` | `GET /v1/billing/usage` |
|
|
298
|
+
| `open_billing_portal(return_url=None, price_lookup_key=None)` | `POST /v1/billing/portal` |
|
|
299
|
+
| `list_logs(limit, cursor, category, action)` | `GET /v1/logs` |
|
|
300
|
+
| `health()` | `GET /api/health` |
|
|
301
|
+
|
|
302
|
+
**The agent helper** (`agent(name)`): `send(...)`, `list_replies(limit=10, since=None)`,
|
|
303
|
+
`reputation()`, the `@on_reply` decorator, `start_listening(blocking=True)` and
|
|
304
|
+
`stop_listening()`. `name` is the agent's name or its id (`agt_…`). When two agents share a
|
|
305
|
+
name (one at `<workspace>.mails.ai` and one on your own domain), a key tied to one of them
|
|
306
|
+
means that one. A workspace key sends from the older one, and its reads and listeners are
|
|
307
|
+
refused with 409 `agent_name_conflict`: pass the id instead. `start_listening` looks the
|
|
308
|
+
agent up before it opens the stream; if that fails (the conflict, or a name no agent has) it
|
|
309
|
+
prints the reason to stderr and returns.
|
|
310
|
+
|
|
311
|
+
**A key tied to one agent** (`create_api_key(agent_id=...)`) reads and sends as that agent
|
|
312
|
+
only. Calls that read across the whole workspace (`usage()`, `list_logs()`,
|
|
313
|
+
`list_connected_apps()`) or change it (webhooks, labels, domains, connected apps, revoking
|
|
314
|
+
keys, creating agents, send-anyway entries) raise `MailsError` with status 403 and code
|
|
315
|
+
`insufficient_scope`. `get_metrics()` counts only that agent's mail, and naming another agent
|
|
316
|
+
in `agent_id` raises the same error. `list_agents()` returns that agent only, `list_api_keys()`
|
|
317
|
+
only the keys tied to it, and `get_agent()` of any other agent raises `MailsError` with status
|
|
318
|
+
404 and code `agent_not_found`, as if it did not exist.
|
|
319
|
+
|
|
320
|
+
## Rate limits and retries
|
|
321
|
+
|
|
322
|
+
A rate-limited call (429) is retried up to three times, and so is a 5xx or a lost connection on a
|
|
323
|
+
read or on one of the five sends (`send`, `batch_send`, `reply`, `forward`, `send_draft`), which
|
|
324
|
+
always carry an idempotency key. Any other write is sent once: sending it again could act twice.
|
|
325
|
+
The API sends `Retry-After` only when the limit lifts within 60 seconds, and the client sleeps
|
|
326
|
+
that long, up to `max_retry_wait` (60 seconds by default, and never more than 60;
|
|
327
|
+
`Client(max_retry_wait=…)` or `create_client(max_retry_wait=…)`).
|
|
328
|
+
|
|
329
|
+
When the limit lifts later (an hourly window that will not clear within the minute, a daily or
|
|
330
|
+
monthly cap), the error body says when: `resets_at` and `retry_after_seconds`. The client then
|
|
331
|
+
does not sleep. It raises `RateLimitError` at once, with `resets_at` (an ISO 8601 time) and
|
|
332
|
+
`retry_after_seconds` set and both named in the message. `resets_at` is `None` when waiting
|
|
333
|
+
never lifts the limit (a plan cap that only an upgrade does). `RateLimitError` is a `MailsError`,
|
|
334
|
+
so code that catches `MailsError` still catches it.
|
|
335
|
+
|
|
336
|
+
`reply`, `forward`, `send_draft` and `batch_send` take `idempotency_key` the same way `send` does:
|
|
337
|
+
a retry with the same key returns the first response instead of sending again.
|
|
338
|
+
|
|
339
|
+
## Attachments
|
|
340
|
+
|
|
341
|
+
Up to 10 files and 25 MB in all. Give each file's bytes, or an https URL that mails.ai downloads when it sends, and give a file a `content_id` to show it inside the HTML instead of attaching it. Bytes travel inside the request, so together they can come to about 3 MB: give a larger file by its URL. A batch cannot carry attachments, so send a message with files on its own:
|
|
342
|
+
|
|
343
|
+
```python
|
|
344
|
+
import base64
|
|
345
|
+
|
|
346
|
+
with open("invoice-221.pdf", "rb") as f:
|
|
347
|
+
pdf = base64.b64encode(f.read()).decode()
|
|
348
|
+
|
|
349
|
+
sarah.send(
|
|
350
|
+
to="dana@example.com",
|
|
351
|
+
subject="Your invoice",
|
|
352
|
+
body_html='<img src="cid:logo" alt="Acme"><p>Your invoice is attached.</p>',
|
|
353
|
+
attachments=[
|
|
354
|
+
{"filename": "logo.png", "path": "https://files.example.com/logo.png", "content_id": "logo"},
|
|
355
|
+
{"filename": "invoice-221.pdf", "content_base64": pdf, "content_type": "application/pdf"},
|
|
356
|
+
],
|
|
357
|
+
)
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
A `path` must answer within 10 seconds, after at most 3 redirects, each to https, and never at a private, loopback or link-local address. A file that cannot be fetched refuses the whole send with `MailsError` (400 `invalid_field`, `param` naming the file: `attachments.0.path`), and nothing is sent. `content_type` is optional with `path`: the type the URL answers with is used. A send with a future `scheduled_at` cannot carry attachments yet.
|
|
361
|
+
|
|
362
|
+
## One-click unsubscribe
|
|
363
|
+
|
|
364
|
+
Sends carry no unsubscribe headers unless you ask. For a newsletter or digest, opt in:
|
|
365
|
+
|
|
366
|
+
```python
|
|
367
|
+
sarah.send(to="reader@example.com", subject="This week's digest", body="…", list_unsubscribe=True)
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
The message then carries `List-Unsubscribe` and `List-Unsubscribe-Post: List-Unsubscribe=One-Click`, and the recipient's mail app shows an Unsubscribe button. One click suppresses that address for your whole workspace, so later sends to it are refused with `recipient_suppressed`. Leave it off for password resets, sign-in links and receipts. It needs exactly one `to` recipient and no `cc` or `bcc`.
|
|
371
|
+
|
|
372
|
+
## Webhook verification
|
|
373
|
+
|
|
374
|
+
```python
|
|
375
|
+
from mailsai import verify_webhook
|
|
376
|
+
|
|
377
|
+
# In your webhook handler:
|
|
378
|
+
event = verify_webhook(
|
|
379
|
+
body=request.body.decode(),
|
|
380
|
+
signature=request.headers["X-Mails-Signature"],
|
|
381
|
+
secret=os.environ["MAILS_WEBHOOK_SECRET"],
|
|
382
|
+
)
|
|
383
|
+
if event is None:
|
|
384
|
+
return Response(status=400)
|
|
385
|
+
# event is the verified inbound event dict
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
## Errors
|
|
389
|
+
|
|
390
|
+
```python
|
|
391
|
+
from mailsai import MailsError, RateLimitError
|
|
392
|
+
|
|
393
|
+
try:
|
|
394
|
+
sarah.send(to="blocked@example.com", subject="…", body="…")
|
|
395
|
+
except RateLimitError as e:
|
|
396
|
+
print("rate limited until", e.resets_at) if e.resets_at else print(e.message)
|
|
397
|
+
except MailsError as e:
|
|
398
|
+
print(e.type, e.code, e.message, e.status, e.request_id)
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
A send that succeeds answers `status` `sent`, `scheduled` (a future `scheduled_at`), or
|
|
402
|
+
`delivered` when delivery to every recipient was confirmed at once (with a live key, a send
|
|
403
|
+
only to the test address `reply@test.mails.ai` always is; a test key's sends answer `sent`). A
|
|
404
|
+
send that fails raises instead.
|
|
405
|
+
|
|
406
|
+
## Configuration
|
|
407
|
+
|
|
408
|
+
| Param / env var | Default |
|
|
409
|
+
|---|---|
|
|
410
|
+
| `api_key` / `MAILS_API_KEY` | (required) |
|
|
411
|
+
| `base_url` / `MAILS_BASE_URL` | `https://api.mails.ai` |
|
|
412
|
+
| `timeout` | `30` seconds |
|
|
413
|
+
| `max_retry_wait` | `60` seconds (at most 60) |
|
|
414
|
+
|
|
415
|
+
## Documentation
|
|
416
|
+
|
|
417
|
+
- Full docs: [mails.ai/docs](https://mails.ai/docs)
|
|
418
|
+
- Source: [github.com/mailsai/mailsai](https://github.com/mailsai/mailsai/tree/main/packages/sdk-py)
|
|
419
|
+
|
|
420
|
+
## License
|
|
421
|
+
|
|
422
|
+
MIT
|