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 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