mailsai 0.1.1__py3-none-any.whl

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/__init__.py ADDED
@@ -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,6 @@
1
+ mailsai/__init__.py,sha256=EZTGuQ97FQAJMubD8PqLueXabY6FDzAmZt0hwgbEL_A,18953
2
+ mailsai-0.1.1.dist-info/licenses/LICENSE,sha256=sV0Eq9KwwUT9u8NXedLY9HFgJqDXQKfYSKpZkIy0oTc,1065
3
+ mailsai-0.1.1.dist-info/METADATA,sha256=jqK9eglzy8PXc8smZfiBoD0BuoaxVzKsv3epeS8_kB4,4857
4
+ mailsai-0.1.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
5
+ mailsai-0.1.1.dist-info/top_level.txt,sha256=icN9BHEdeJ0m9dXR6aZjhT_Dr7YZx3yHJdU1yMcmKOQ,8
6
+ mailsai-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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.
@@ -0,0 +1 @@
1
+ mailsai