primitif
Primitif Python SDK — Mail and Approval APIs for AI agents.
1"""Primitif Python SDK — Mail and Approval APIs for AI agents.""" 2 3from primitif._models import PaginatedList 4from primitif._version import __version__ 5from primitif._exceptions import ( 6 PrimitifError, 7 AuthError, 8 NotFoundError, 9 ConflictError, 10 ValidationError, 11 RateLimitError, 12 ServerError, 13 NetworkError, 14 InvalidSignature, 15) 16from primitif._webhook import verify_webhook 17from primitif.client import Primitif 18from primitif.mail.client import Mailbox 19from primitif.approval import require_approval 20 21__all__ = [ 22 "__version__", 23 "Primitif", 24 "Mailbox", 25 "PaginatedList", 26 "verify_webhook", 27 "PrimitifError", 28 "AuthError", 29 "NotFoundError", 30 "ConflictError", 31 "ValidationError", 32 "RateLimitError", 33 "ServerError", 34 "NetworkError", 35 "InvalidSignature", 36 "require_approval", 37]
23class Primitif: 24 """Primitif SDK client — one API key, access to all products. 25 26 Usage:: 27 28 with Primitif() as p: # reads PRIMITIF_API_KEY from env 29 mb = p.mail.create_mailbox(name="agent", ttl=3600) 30 mb.send(to="user@example.com", subject="Hi", body_text="Hello") 31 mb.delete() 32 33 req = p.approval.create_request(action="Send contract") 34 35 ns = p.namespaces.create("staging") 36 """ 37 38 def __init__( 39 self, 40 api_key: str | None = None, 41 *, 42 mail_url: str | None = None, 43 approval_url: str | None = None, 44 audit_url: str | None = None, 45 platform_url: str | None = None, 46 max_retries: int = DEFAULT_MAX_RETRIES, 47 timeout: float = 30.0, 48 ) -> None: 49 """Create a Primitif client with access to all products. 50 51 Args: 52 api_key: Developer API key (``pk_live_...``). Falls back to 53 the ``PRIMITIF_API_KEY`` environment variable if omitted. 54 mail_url: Mail API base URL. Defaults to ``PRIMITIF_MAIL_URL`` 55 env var or ``https://api.mail.primitif.ai``. 56 approval_url: Approval API base URL. Defaults to 57 ``PRIMITIF_APPROVAL_URL`` env var or 58 ``https://api.approval.primitif.ai``. 59 audit_url: Audit API base URL. Defaults to 60 ``PRIMITIF_AUDIT_URL`` env var or 61 ``https://api.audit.primitif.ai``. 62 platform_url: Platform API base URL. Defaults to 63 ``PRIMITIF_PLATFORM_URL`` env var or 64 ``https://api.primitif.ai``. 65 max_retries: Number of automatic retries on transient errors. 66 Defaults to 2. 67 timeout: HTTP request timeout in seconds. Defaults to 30.0. 68 69 Raises: 70 PrimitifError: If no API key is provided and ``PRIMITIF_API_KEY`` 71 is not set. 72 73 Example: 74 >>> with Primitif() as p: 75 ... mb = p.mail.create_mailbox(name="agent", ttl=3600) 76 ... mb.send(to="user@example.com", subject="Hi", body_text="Hello") 77 ... mb.delete() 78 """ 79 from primitif._version import __version__ 80 81 if api_key is None: 82 api_key = os.environ.get("PRIMITIF_API_KEY") 83 if api_key is not None: 84 api_key = api_key.strip() 85 if not api_key: 86 raise PrimitifError( 87 message="No API key provided. Pass api_key= or set PRIMITIF_API_KEY env var." 88 ) 89 90 if mail_url is None: 91 mail_url = os.environ.get("PRIMITIF_MAIL_URL", DEFAULT_MAIL_URL) 92 if approval_url is None: 93 approval_url = os.environ.get("PRIMITIF_APPROVAL_URL", DEFAULT_APPROVAL_URL) 94 if audit_url is None: 95 audit_url = os.environ.get("PRIMITIF_AUDIT_URL", DEFAULT_AUDIT_URL) 96 if platform_url is None: 97 platform_url = os.environ.get("PRIMITIF_PLATFORM_URL", DEFAULT_PLATFORM_URL) 98 99 headers = { 100 "Authorization": f"Bearer {api_key}", 101 "User-Agent": f"primitif/{__version__}", 102 } 103 104 self._mail_client = httpx.Client( 105 base_url=mail_url.rstrip("/"), 106 headers=headers, 107 timeout=timeout, 108 ) 109 try: 110 self._approval_client = httpx.Client( 111 base_url=approval_url.rstrip("/"), 112 headers=headers, 113 timeout=timeout, 114 ) 115 except BaseException: 116 self._mail_client.close() 117 raise 118 try: 119 self._audit_client = httpx.Client( 120 base_url=audit_url.rstrip("/"), 121 headers=headers, 122 timeout=timeout, 123 ) 124 except BaseException: 125 self._mail_client.close() 126 self._approval_client.close() 127 raise 128 try: 129 self._platform_client = httpx.Client( 130 base_url=platform_url.rstrip("/"), 131 headers=headers, 132 timeout=timeout, 133 ) 134 except BaseException: 135 self._mail_client.close() 136 self._approval_client.close() 137 self._audit_client.close() 138 raise 139 self._max_retries = max_retries 140 141 self.mail = MailAdmin(self._mail_client, max_retries, mail_url.rstrip("/")) 142 self.approval = ApprovalAdmin(self._approval_client, max_retries) 143 self.audit = AuditAdmin(self._audit_client, max_retries) 144 self.namespaces = NamespaceAdmin(self._platform_client, max_retries) 145 146 def close(self) -> None: 147 """Close all HTTP clients. 148 149 Releases the underlying connection pools for Mail, Approval, Audit, 150 and Platform APIs. Called automatically when used as a context manager. 151 152 Example: 153 >>> p = Primitif() 154 >>> # ... use p ... 155 >>> p.close() 156 """ 157 self._mail_client.close() 158 self._approval_client.close() 159 self._audit_client.close() 160 self._platform_client.close() 161 162 def __enter__(self) -> Primitif: 163 return self 164 165 def __exit__(self, *args) -> None: 166 self.close() 167 168 def __repr__(self) -> str: 169 return "Primitif()"
Primitif SDK client — one API key, access to all products.
Usage::
with Primitif() as p: # reads PRIMITIF_API_KEY from env
mb = p.mail.create_mailbox(name="agent", ttl=3600)
mb.send(to="user@example.com", subject="Hi", body_text="Hello")
mb.delete()
req = p.approval.create_request(action="Send contract")
ns = p.namespaces.create("staging")
38 def __init__( 39 self, 40 api_key: str | None = None, 41 *, 42 mail_url: str | None = None, 43 approval_url: str | None = None, 44 audit_url: str | None = None, 45 platform_url: str | None = None, 46 max_retries: int = DEFAULT_MAX_RETRIES, 47 timeout: float = 30.0, 48 ) -> None: 49 """Create a Primitif client with access to all products. 50 51 Args: 52 api_key: Developer API key (``pk_live_...``). Falls back to 53 the ``PRIMITIF_API_KEY`` environment variable if omitted. 54 mail_url: Mail API base URL. Defaults to ``PRIMITIF_MAIL_URL`` 55 env var or ``https://api.mail.primitif.ai``. 56 approval_url: Approval API base URL. Defaults to 57 ``PRIMITIF_APPROVAL_URL`` env var or 58 ``https://api.approval.primitif.ai``. 59 audit_url: Audit API base URL. Defaults to 60 ``PRIMITIF_AUDIT_URL`` env var or 61 ``https://api.audit.primitif.ai``. 62 platform_url: Platform API base URL. Defaults to 63 ``PRIMITIF_PLATFORM_URL`` env var or 64 ``https://api.primitif.ai``. 65 max_retries: Number of automatic retries on transient errors. 66 Defaults to 2. 67 timeout: HTTP request timeout in seconds. Defaults to 30.0. 68 69 Raises: 70 PrimitifError: If no API key is provided and ``PRIMITIF_API_KEY`` 71 is not set. 72 73 Example: 74 >>> with Primitif() as p: 75 ... mb = p.mail.create_mailbox(name="agent", ttl=3600) 76 ... mb.send(to="user@example.com", subject="Hi", body_text="Hello") 77 ... mb.delete() 78 """ 79 from primitif._version import __version__ 80 81 if api_key is None: 82 api_key = os.environ.get("PRIMITIF_API_KEY") 83 if api_key is not None: 84 api_key = api_key.strip() 85 if not api_key: 86 raise PrimitifError( 87 message="No API key provided. Pass api_key= or set PRIMITIF_API_KEY env var." 88 ) 89 90 if mail_url is None: 91 mail_url = os.environ.get("PRIMITIF_MAIL_URL", DEFAULT_MAIL_URL) 92 if approval_url is None: 93 approval_url = os.environ.get("PRIMITIF_APPROVAL_URL", DEFAULT_APPROVAL_URL) 94 if audit_url is None: 95 audit_url = os.environ.get("PRIMITIF_AUDIT_URL", DEFAULT_AUDIT_URL) 96 if platform_url is None: 97 platform_url = os.environ.get("PRIMITIF_PLATFORM_URL", DEFAULT_PLATFORM_URL) 98 99 headers = { 100 "Authorization": f"Bearer {api_key}", 101 "User-Agent": f"primitif/{__version__}", 102 } 103 104 self._mail_client = httpx.Client( 105 base_url=mail_url.rstrip("/"), 106 headers=headers, 107 timeout=timeout, 108 ) 109 try: 110 self._approval_client = httpx.Client( 111 base_url=approval_url.rstrip("/"), 112 headers=headers, 113 timeout=timeout, 114 ) 115 except BaseException: 116 self._mail_client.close() 117 raise 118 try: 119 self._audit_client = httpx.Client( 120 base_url=audit_url.rstrip("/"), 121 headers=headers, 122 timeout=timeout, 123 ) 124 except BaseException: 125 self._mail_client.close() 126 self._approval_client.close() 127 raise 128 try: 129 self._platform_client = httpx.Client( 130 base_url=platform_url.rstrip("/"), 131 headers=headers, 132 timeout=timeout, 133 ) 134 except BaseException: 135 self._mail_client.close() 136 self._approval_client.close() 137 self._audit_client.close() 138 raise 139 self._max_retries = max_retries 140 141 self.mail = MailAdmin(self._mail_client, max_retries, mail_url.rstrip("/")) 142 self.approval = ApprovalAdmin(self._approval_client, max_retries) 143 self.audit = AuditAdmin(self._audit_client, max_retries) 144 self.namespaces = NamespaceAdmin(self._platform_client, max_retries)
Create a Primitif client with access to all products.
Args:
api_key: Developer API key (pk_live_...). Falls back to
the PRIMITIF_API_KEY environment variable if omitted.
mail_url: Mail API base URL. Defaults to PRIMITIF_MAIL_URL
env var or https://api.mail.primitif.ai.
approval_url: Approval API base URL. Defaults to
PRIMITIF_APPROVAL_URL env var or
https://api.approval.primitif.ai.
audit_url: Audit API base URL. Defaults to
PRIMITIF_AUDIT_URL env var or
https://api.audit.primitif.ai.
platform_url: Platform API base URL. Defaults to
PRIMITIF_PLATFORM_URL env var or
https://api.primitif.ai.
max_retries: Number of automatic retries on transient errors.
Defaults to 2.
timeout: HTTP request timeout in seconds. Defaults to 30.0.
Raises:
PrimitifError: If no API key is provided and PRIMITIF_API_KEY
is not set.
Example:
with Primitif() as p: ... mb = p.mail.create_mailbox(name="agent", ttl=3600) ... mb.send(to="user@example.com", subject="Hi", body_text="Hello") ... mb.delete()
146 def close(self) -> None: 147 """Close all HTTP clients. 148 149 Releases the underlying connection pools for Mail, Approval, Audit, 150 and Platform APIs. Called automatically when used as a context manager. 151 152 Example: 153 >>> p = Primitif() 154 >>> # ... use p ... 155 >>> p.close() 156 """ 157 self._mail_client.close() 158 self._approval_client.close() 159 self._audit_client.close() 160 self._platform_client.close()
Close all HTTP clients.
Releases the underlying connection pools for Mail, Approval, Audit, and Platform APIs. Called automatically when used as a context manager.
Example:
p = Primitif()
... use p ...
p.close()
63class Mailbox: 64 """A single ephemeral mailbox — send, receive, and manage email. 65 66 Typically created via ``p.mail.create_mailbox()``, but can also 67 be instantiated directly with a token for agent-scoped operations. 68 69 Usage:: 70 71 with Mailbox() as mb: # reads PRIMITIF_MAIL_TOKEN from env 72 mb.send(to="user@example.com", subject="Hi", body_text="Hello") 73 for msg in mb.inbox(): 74 detail = mb.read(msg.id) 75 mb.delete() 76 """ 77 78 def __init__( 79 self, 80 token: str | None = None, 81 *, 82 base_url: str | None = None, 83 max_retries: int = DEFAULT_MAX_RETRIES, 84 timeout: float = 30.0, 85 _client: httpx.Client | None = None, 86 ) -> None: 87 """Create a Mailbox client for agent-scoped email operations. 88 89 Args: 90 token: Mailbox token (``mb_tok_...``). Falls back to the 91 ``PRIMITIF_MAIL_TOKEN`` environment variable if omitted. 92 base_url: Mail API base URL. Defaults to ``PRIMITIF_MAIL_URL`` 93 env var or ``https://api.mail.primitif.ai``. 94 max_retries: Number of automatic retries on transient errors. 95 Defaults to 2. 96 timeout: HTTP request timeout in seconds. Defaults to 30.0. 97 98 Raises: 99 MailError: If no token is provided and ``PRIMITIF_MAIL_TOKEN`` 100 is not set. 101 102 Example: 103 >>> mb = Mailbox(token="mb_tok_abc123") 104 >>> print(mb.address) 105 'agent-7f3a@mail.primitif.ai' 106 """ 107 self._destroyed = False 108 self._token: str | None = None 109 110 if _client is not None: 111 # Internal: created by MailAdmin.create_mailbox() 112 self._client = _client 113 self._max_retries = max_retries 114 self._info_cache: MailboxInfo | None = None 115 self._owns_client = False 116 return 117 118 from primitif._version import __version__ 119 120 if token is None: 121 token = os.environ.get("PRIMITIF_MAIL_TOKEN") 122 if token is not None: 123 token = token.strip() 124 if not token: 125 raise MailError( 126 message="No token provided. Pass token= or set PRIMITIF_MAIL_TOKEN env var." 127 ) 128 129 self._token = token 130 131 if base_url is None: 132 base_url = os.environ.get("PRIMITIF_MAIL_URL", DEFAULT_BASE_URL) 133 134 self._max_retries = max_retries 135 self._info_cache = None 136 self._owns_client = True 137 self._client = httpx.Client( 138 base_url=base_url.rstrip("/"), 139 headers={ 140 "Authorization": f"Bearer {token}", 141 "User-Agent": f"primitif/{__version__}", 142 }, 143 timeout=timeout, 144 ) 145 146 def _request(self, method: str, path: str, **kwargs) -> dict: 147 if self._destroyed: 148 raise MailError(message="Mailbox has been destroyed.") 149 return request_with_retries( 150 self._client, 151 method, 152 path, 153 self._max_retries, 154 exc_map=_EXC_MAP, 155 server_exc=MailServerError, 156 base_exc=MailError, 157 connection_exc=MailNetworkError, 158 logger=logger, 159 **kwargs, 160 ) 161 162 # ── Info ── 163 164 @property 165 def address(self) -> str: 166 """The mailbox email address (fetched and cached). 167 168 Calls ``info()`` on first access to populate the cache. 169 170 Returns: 171 The full email address (e.g. ``"agent-7f3a@mail.primitif.ai"``). 172 173 Example: 174 >>> print(mb.address) 175 'agent-7f3a@mail.primitif.ai' 176 """ 177 if self._info_cache is None: 178 self.info() 179 return self._info_cache.address 180 181 @property 182 def token(self) -> str | None: 183 """The mailbox token, if available. 184 185 Present when created via ``p.mail.create_mailbox()`` or when 186 ``token=`` was passed to the constructor. Useful for persisting 187 tokens so a crashed agent can reconnect with ``Mailbox(token=...)``. 188 189 Returns: 190 The token string (``mb_tok_...``) or None if not available. 191 192 Example: 193 >>> token = mb.token 194 >>> # persist token, then later: 195 >>> mb = Mailbox(token=token) 196 """ 197 return self._token 198 199 def info(self) -> MailboxInfo: 200 """Get mailbox details. 201 202 Returns: 203 MailboxInfo with ``id``, ``address``, ``name``, ``expires_at``, 204 and ``created_at`` fields. 205 206 Raises: 207 MailAuthError: If the token is invalid or expired. 208 MailNetworkError: On connection failure. 209 210 Example: 211 >>> info = mb.info() 212 >>> print(info.address, info.expires_at) 213 """ 214 data = self._request("GET", "/v1/me") 215 result = MailboxInfo(**data) 216 self._info_cache = result 217 return result 218 219 # ── Inbox & messages ── 220 221 def inbox( 222 self, 223 *, 224 unread_only: bool = False, 225 limit: int = 20, 226 cursor: str | None = None, 227 ) -> PaginatedList[InboxMessage]: 228 """List inbox messages. 229 230 Args: 231 unread_only: If True, return only unread messages. Defaults to False. 232 limit: Maximum number of messages per page. Defaults to 20. 233 cursor: Pagination cursor from a previous response. 234 235 Returns: 236 PaginatedList[InboxMessage] supporting iteration and 237 ``.auto_paging_iter()`` for automatic pagination. 238 239 Raises: 240 MailAuthError: If the token is invalid. 241 MailNetworkError: On connection failure. 242 243 Example: 244 >>> for msg in mb.inbox(unread_only=True): 245 ... print(msg.from_address, msg.subject) 246 >>> # Auto-paginate through all messages: 247 >>> for msg in mb.inbox().auto_paging_iter(): 248 ... print(msg.id) 249 """ 250 params: dict = {"limit": limit} 251 if unread_only: 252 params["unread_only"] = "true" 253 if cursor is not None: 254 params["cursor"] = cursor 255 data = self._request("GET", "/v1/me/inbox", params=params) 256 items = [InboxMessage(**m) for m in data["items"]] 257 result = PaginatedList( 258 items=items, cursor=data.get("cursor"), has_more=data.get("has_more", False) 259 ) 260 result._fetch_fn = self.inbox 261 result._fetch_kwargs = {"unread_only": unread_only, "limit": limit} 262 return result 263 264 def read(self, message_id: str) -> MessageDetail: 265 """Read a full message. 266 267 Marks the message as read on the server. 268 269 Args: 270 message_id: The message ID (from InboxMessage.id). 271 272 Returns: 273 MessageDetail with full body, headers, and auth results 274 (``spf``, ``dkim``, ``dmarc``). 275 276 Raises: 277 MailNotFoundError: If the message does not exist. 278 MailAuthError: If the token is invalid. 279 280 Example: 281 >>> detail = mb.read("msg_abc123") 282 >>> print(detail.body_text) 283 """ 284 data = self._request("GET", f"/v1/me/messages/{message_id}") 285 return MessageDetail(**data) 286 287 def threads( 288 self, 289 *, 290 limit: int = 20, 291 cursor: str | None = None, 292 ) -> PaginatedList[ThreadSummary]: 293 """List email threads. 294 295 Args: 296 limit: Maximum number of threads per page. Defaults to 20. 297 cursor: Pagination cursor from a previous response. 298 299 Returns: 300 PaginatedList[ThreadSummary] supporting iteration and 301 ``.auto_paging_iter()``. 302 303 Raises: 304 MailAuthError: If the token is invalid. 305 MailNetworkError: On connection failure. 306 307 Example: 308 >>> for t in mb.threads(): 309 ... print(t.subject, t.message_count) 310 """ 311 params: dict = {"limit": limit} 312 if cursor is not None: 313 params["cursor"] = cursor 314 data = self._request("GET", "/v1/me/threads", params=params) 315 items = [ThreadSummary(**t) for t in data["items"]] 316 result = PaginatedList( 317 items=items, cursor=data.get("cursor"), has_more=data.get("has_more", False) 318 ) 319 result._fetch_fn = self.threads 320 result._fetch_kwargs = {"limit": limit} 321 return result 322 323 def thread(self, thread_id: str) -> ThreadDetail: 324 """Get a full thread with all messages. 325 326 Args: 327 thread_id: The thread ID. 328 329 Returns: 330 ThreadDetail containing the thread metadata and a ``messages`` 331 list of MessageDetail objects. 332 333 Raises: 334 MailNotFoundError: If the thread does not exist. 335 MailAuthError: If the token is invalid. 336 337 Example: 338 >>> t = mb.thread("thr_abc123") 339 >>> for msg in t.messages: 340 ... print(msg.from_address, msg.subject) 341 """ 342 data = self._request("GET", f"/v1/me/threads/{thread_id}") 343 messages = [MessageDetail(**m) for m in data.pop("messages", [])] 344 return ThreadDetail(**data, messages=messages) 345 346 # ── Sending ── 347 348 def send( 349 self, 350 *, 351 to: str, 352 subject: str, 353 body_text: str, 354 body_html: str | None = None, 355 require_approval: bool = False, 356 ) -> SendResult: 357 """Send an email from this mailbox. 358 359 Args: 360 to: Recipient email address. 361 subject: Email subject line. 362 body_text: Plain-text email body. 363 body_html: Optional HTML email body. 364 require_approval: If True, the email is held for human approval 365 before sending. Defaults to False. 366 367 Returns: 368 SendResult with ``thread_id``, ``message_id``, and ``status`` 369 (``"sent"``, ``"held"``, or ``"pending_approval"``). When 370 approval is required, ``approval_url`` is set. 371 372 Raises: 373 MailValidationError: If the payload is invalid (e.g. bad address). 374 MailAuthError: If the token is invalid. 375 376 Example: 377 >>> result = mb.send( 378 ... to="user@example.com", 379 ... subject="Invoice #42", 380 ... body_text="Please find your invoice attached.", 381 ... ) 382 >>> print(result.message_id) 383 """ 384 payload: dict = {"to": to, "subject": subject, "body_text": body_text} 385 if body_html is not None: 386 payload["body_html"] = body_html 387 if require_approval: 388 payload["require_approval"] = True 389 data = self._request("POST", "/v1/me/send", json=payload) 390 return SendResult(**data) 391 392 def reply( 393 self, 394 message_id: str, 395 *, 396 body_text: str, 397 body_html: str | None = None, 398 require_approval: bool = False, 399 ) -> SendResult: 400 """Reply to a message. 401 402 Sends a reply in the same thread as the original message. 403 404 Args: 405 message_id: The ID of the message to reply to. 406 body_text: Plain-text reply body. 407 body_html: Optional HTML reply body. 408 require_approval: If True, the reply is held for human approval. 409 Defaults to False. 410 411 Returns: 412 SendResult with ``thread_id``, ``message_id``, and ``status``. 413 414 Raises: 415 MailNotFoundError: If the original message does not exist. 416 MailValidationError: If the payload is invalid. 417 MailAuthError: If the token is invalid. 418 419 Example: 420 >>> for msg in mb.inbox(unread_only=True): 421 ... mb.reply(msg.id, body_text="Got it, thanks!") 422 """ 423 payload: dict = {"body_text": body_text} 424 if body_html is not None: 425 payload["body_html"] = body_html 426 if require_approval: 427 payload["require_approval"] = True 428 data = self._request("POST", f"/v1/me/reply/{message_id}", json=payload) 429 return SendResult(**data) 430 431 # ── Attachments ── 432 433 def list_attachments(self, message_id: str) -> list[AttachmentInfo]: 434 """List attachments on a message. 435 436 Args: 437 message_id: The message ID. 438 439 Returns: 440 List of AttachmentInfo with ``id``, ``filename``, 441 ``content_type``, and ``size_bytes``. 442 443 Raises: 444 MailNotFoundError: If the message does not exist. 445 MailAuthError: If the token is invalid. 446 447 Example: 448 >>> attachments = mb.list_attachments("msg_abc123") 449 >>> for a in attachments: 450 ... print(a.filename, a.size_bytes) 451 """ 452 data = self._request("GET", f"/v1/me/messages/{message_id}/attachments") 453 return [AttachmentInfo(**a) for a in data["items"]] 454 455 def download_attachment(self, message_id: str, attachment_id: str) -> bytes: 456 """Download a raw attachment. 457 458 Args: 459 message_id: The message ID containing the attachment. 460 attachment_id: The attachment ID (from AttachmentInfo.id). 461 462 Returns: 463 Raw file bytes. 464 465 Raises: 466 MailNotFoundError: If the message or attachment does not exist. 467 MailAuthError: If the token is invalid. 468 MailNetworkError: On connection failure after retries. 469 470 Example: 471 >>> data = mb.download_attachment("msg_abc123", "att_def456") 472 >>> with open("invoice.pdf", "wb") as f: 473 ... f.write(data) 474 """ 475 if self._destroyed: 476 raise MailError(message="Mailbox has been destroyed.") 477 path = f"/v1/me/messages/{message_id}/attachments/{attachment_id}" 478 last_response: httpx.Response | None = None 479 last_exc: httpx.TransportError | None = None 480 for attempt in range(self._max_retries + 1): 481 try: 482 response = self._client.request("GET", path) 483 except (httpx.TimeoutException, httpx.ConnectError) as exc: 484 last_exc = exc 485 if attempt < self._max_retries: 486 _time.sleep(_transport_retry_delay(attempt)) 487 continue 488 raise MailNetworkError( 489 message=f"{type(exc).__name__}: {exc}", 490 ) from exc 491 except httpx.TransportError as exc: 492 raise MailNetworkError( 493 message=f"{type(exc).__name__}: {exc}", 494 ) from exc 495 last_exc = None 496 last_response = response 497 if response.status_code in RETRY_STATUS_CODES and attempt < self._max_retries: 498 _time.sleep(_retry_delay(response, attempt)) 499 continue 500 break 501 if last_response is None: 502 raise MailNetworkError( 503 message=f"{type(last_exc).__name__}: {last_exc}", 504 ) from last_exc 505 raise_for_status( 506 last_response, 507 exc_map=_EXC_MAP, 508 server_exc=MailServerError, 509 base_exc=MailError, 510 ) 511 return last_response.content 512 513 # ── Allowlist ── 514 515 def get_allowlist(self) -> list[AllowlistEntry]: 516 """List allowed senders. 517 518 Returns: 519 List of AllowlistEntry with ``id``, ``sender_address``, and 520 ``created_at``. 521 522 Raises: 523 MailAuthError: If the token is invalid. 524 525 Example: 526 >>> for entry in mb.get_allowlist(): 527 ... print(entry.sender_address) 528 """ 529 data = self._request("GET", "/v1/me/allowlist") 530 return [AllowlistEntry(**e) for e in data["items"]] 531 532 def add_allowlist(self, sender_address: str) -> AllowlistEntry: 533 """Add an allowed sender. 534 535 Only emails from allowlisted senders will be delivered to this 536 mailbox (when the allowlist is non-empty). 537 538 Args: 539 sender_address: Email address to allow (e.g. ``"noreply@github.com"``). 540 541 Returns: 542 The created AllowlistEntry. 543 544 Raises: 545 MailConflictError: If the address is already allowlisted. 546 MailValidationError: If the address is invalid. 547 MailAuthError: If the token is invalid. 548 549 Example: 550 >>> entry = mb.add_allowlist("noreply@github.com") 551 >>> print(entry.id) 552 """ 553 data = self._request( 554 "POST", "/v1/me/allowlist", json={"sender_address": sender_address} 555 ) 556 return AllowlistEntry(**data) 557 558 def remove_allowlist(self, entry_id: str) -> None: 559 """Remove an allowed sender. 560 561 Args: 562 entry_id: The allowlist entry ID (from AllowlistEntry.id). 563 564 Raises: 565 MailNotFoundError: If the entry does not exist. 566 MailAuthError: If the token is invalid. 567 568 Example: 569 >>> mb.remove_allowlist("ale_abc123") 570 """ 571 self._request("DELETE", f"/v1/me/allowlist/{entry_id}") 572 573 # ── Auth policy ── 574 575 def get_auth_policy(self) -> str: 576 """Get the email authentication policy. 577 578 Returns: 579 The current policy as a string: ``"none"``, ``"warn"``, or 580 ``"reject"``. 581 582 Raises: 583 MailAuthError: If the token is invalid. 584 585 Example: 586 >>> policy = mb.get_auth_policy() 587 >>> print(policy) # "none", "warn", or "reject" 588 """ 589 data = self._request("GET", "/v1/me/auth-policy") 590 return data["auth_policy"] 591 592 def set_auth_policy(self, policy: Literal["none", "warn", "reject"]) -> None: 593 """Set the email authentication policy. 594 595 Controls how the mailbox handles emails that fail SPF/DKIM/DMARC 596 checks. 597 598 Args: 599 policy: One of ``"none"`` (accept all), ``"warn"`` (accept but 600 flag), or ``"reject"`` (reject failing emails). 601 602 Raises: 603 MailValidationError: If the policy value is invalid. 604 MailAuthError: If the token is invalid. 605 606 Example: 607 >>> mb.set_auth_policy("reject") 608 """ 609 self._request("PUT", "/v1/me/auth-policy", json={"auth_policy": policy}) 610 611 # ── Webhooks ── 612 613 def set_webhook(self, url: str, events: list[str] | None = None) -> WebhookCreated: 614 """Register a webhook. Returns WebhookCreated with signing secret (shown once). 615 616 Replaces any existing webhook on this mailbox. 617 618 Args: 619 url: The HTTPS URL to receive webhook POST requests. 620 events: List of event types to subscribe to (e.g. 621 ``["message.received"]``). Omit to subscribe to all events. 622 623 Returns: 624 WebhookCreated with ``id``, ``url``, ``secret`` (one-time), 625 and ``events``. 626 627 Raises: 628 MailValidationError: If the URL is invalid. 629 MailAuthError: If the token is invalid. 630 631 Example: 632 >>> wh = mb.set_webhook("https://example.com/hook") 633 >>> print(wh.secret) # save this -- shown only once 634 """ 635 payload: dict = {"url": url} 636 if events is not None: 637 payload["events"] = events 638 data = self._request("POST", "/v1/me/webhook", json=payload) 639 return WebhookCreated(**data) 640 641 def webhook(self) -> WebhookInfo: 642 """Get current webhook config. 643 644 Returns: 645 WebhookInfo with ``id``, ``url``, ``events``, and ``created_at``. 646 647 Raises: 648 MailNotFoundError: If no webhook is configured. 649 MailAuthError: If the token is invalid. 650 651 Example: 652 >>> wh = mb.webhook() 653 >>> print(wh.url, wh.events) 654 """ 655 data = self._request("GET", "/v1/me/webhook") 656 return WebhookInfo(**data) 657 658 def delete_webhook(self) -> None: 659 """Remove the webhook. 660 661 Raises: 662 MailNotFoundError: If no webhook is configured. 663 MailAuthError: If the token is invalid. 664 665 Example: 666 >>> mb.delete_webhook() 667 """ 668 self._request("DELETE", "/v1/me/webhook") 669 670 # ── Lifecycle ── 671 672 def delete(self) -> None: 673 """Delete this mailbox from the server and close the HTTP client. 674 675 This permanently destroys the mailbox and all its messages. The 676 Mailbox instance cannot be used after this call. 677 678 Raises: 679 MailAuthError: If the token is invalid. 680 MailNetworkError: On connection failure. 681 682 Example: 683 >>> mb.delete() # mailbox is gone 684 """ 685 self._request("DELETE", "/v1/me") 686 self._destroyed = True 687 if self._owns_client: 688 self._client.close() 689 690 def close(self) -> None: 691 """Close the HTTP client. 692 693 The mailbox remains active on the server. Use ``delete()`` to 694 destroy it entirely. Called automatically when used as a context 695 manager. 696 697 Example: 698 >>> mb.close() 699 """ 700 if self._owns_client: 701 self._client.close() 702 703 def __enter__(self) -> Mailbox: 704 return self 705 706 def __exit__(self, *args) -> None: 707 self.close() 708 709 def __repr__(self) -> str: 710 if self._info_cache: 711 return f"Mailbox({self._info_cache.address})" 712 return "Mailbox()"
A single ephemeral mailbox — send, receive, and manage email.
Typically created via p.mail.create_mailbox(), but can also
be instantiated directly with a token for agent-scoped operations.
Usage::
with Mailbox() as mb: # reads PRIMITIF_MAIL_TOKEN from env
mb.send(to="user@example.com", subject="Hi", body_text="Hello")
for msg in mb.inbox():
detail = mb.read(msg.id)
mb.delete()
78 def __init__( 79 self, 80 token: str | None = None, 81 *, 82 base_url: str | None = None, 83 max_retries: int = DEFAULT_MAX_RETRIES, 84 timeout: float = 30.0, 85 _client: httpx.Client | None = None, 86 ) -> None: 87 """Create a Mailbox client for agent-scoped email operations. 88 89 Args: 90 token: Mailbox token (``mb_tok_...``). Falls back to the 91 ``PRIMITIF_MAIL_TOKEN`` environment variable if omitted. 92 base_url: Mail API base URL. Defaults to ``PRIMITIF_MAIL_URL`` 93 env var or ``https://api.mail.primitif.ai``. 94 max_retries: Number of automatic retries on transient errors. 95 Defaults to 2. 96 timeout: HTTP request timeout in seconds. Defaults to 30.0. 97 98 Raises: 99 MailError: If no token is provided and ``PRIMITIF_MAIL_TOKEN`` 100 is not set. 101 102 Example: 103 >>> mb = Mailbox(token="mb_tok_abc123") 104 >>> print(mb.address) 105 'agent-7f3a@mail.primitif.ai' 106 """ 107 self._destroyed = False 108 self._token: str | None = None 109 110 if _client is not None: 111 # Internal: created by MailAdmin.create_mailbox() 112 self._client = _client 113 self._max_retries = max_retries 114 self._info_cache: MailboxInfo | None = None 115 self._owns_client = False 116 return 117 118 from primitif._version import __version__ 119 120 if token is None: 121 token = os.environ.get("PRIMITIF_MAIL_TOKEN") 122 if token is not None: 123 token = token.strip() 124 if not token: 125 raise MailError( 126 message="No token provided. Pass token= or set PRIMITIF_MAIL_TOKEN env var." 127 ) 128 129 self._token = token 130 131 if base_url is None: 132 base_url = os.environ.get("PRIMITIF_MAIL_URL", DEFAULT_BASE_URL) 133 134 self._max_retries = max_retries 135 self._info_cache = None 136 self._owns_client = True 137 self._client = httpx.Client( 138 base_url=base_url.rstrip("/"), 139 headers={ 140 "Authorization": f"Bearer {token}", 141 "User-Agent": f"primitif/{__version__}", 142 }, 143 timeout=timeout, 144 )
Create a Mailbox client for agent-scoped email operations.
Args:
token: Mailbox token (mb_tok_...). Falls back to the
PRIMITIF_MAIL_TOKEN environment variable if omitted.
base_url: Mail API base URL. Defaults to PRIMITIF_MAIL_URL
env var or https://api.mail.primitif.ai.
max_retries: Number of automatic retries on transient errors.
Defaults to 2.
timeout: HTTP request timeout in seconds. Defaults to 30.0.
Raises:
MailError: If no token is provided and PRIMITIF_MAIL_TOKEN
is not set.
Example:
mb = Mailbox(token="mb_tok_abc123") print(mb.address) 'agent-7f3a@mail.primitif.ai'
164 @property 165 def address(self) -> str: 166 """The mailbox email address (fetched and cached). 167 168 Calls ``info()`` on first access to populate the cache. 169 170 Returns: 171 The full email address (e.g. ``"agent-7f3a@mail.primitif.ai"``). 172 173 Example: 174 >>> print(mb.address) 175 'agent-7f3a@mail.primitif.ai' 176 """ 177 if self._info_cache is None: 178 self.info() 179 return self._info_cache.address
The mailbox email address (fetched and cached).
Calls info() on first access to populate the cache.
Returns:
The full email address (e.g. "agent-7f3a@mail.primitif.ai").
Example:
print(mb.address) 'agent-7f3a@mail.primitif.ai'
181 @property 182 def token(self) -> str | None: 183 """The mailbox token, if available. 184 185 Present when created via ``p.mail.create_mailbox()`` or when 186 ``token=`` was passed to the constructor. Useful for persisting 187 tokens so a crashed agent can reconnect with ``Mailbox(token=...)``. 188 189 Returns: 190 The token string (``mb_tok_...``) or None if not available. 191 192 Example: 193 >>> token = mb.token 194 >>> # persist token, then later: 195 >>> mb = Mailbox(token=token) 196 """ 197 return self._token
The mailbox token, if available.
Present when created via p.mail.create_mailbox() or when
token= was passed to the constructor. Useful for persisting
tokens so a crashed agent can reconnect with Mailbox(token=...).
Returns:
The token string (mb_tok_...) or None if not available.
Example:
token = mb.token
persist token, then later:
mb = Mailbox(token=token)
199 def info(self) -> MailboxInfo: 200 """Get mailbox details. 201 202 Returns: 203 MailboxInfo with ``id``, ``address``, ``name``, ``expires_at``, 204 and ``created_at`` fields. 205 206 Raises: 207 MailAuthError: If the token is invalid or expired. 208 MailNetworkError: On connection failure. 209 210 Example: 211 >>> info = mb.info() 212 >>> print(info.address, info.expires_at) 213 """ 214 data = self._request("GET", "/v1/me") 215 result = MailboxInfo(**data) 216 self._info_cache = result 217 return result
Get mailbox details.
Returns:
MailboxInfo with id, address, name, expires_at,
and created_at fields.
Raises: MailAuthError: If the token is invalid or expired. MailNetworkError: On connection failure.
Example:
info = mb.info() print(info.address, info.expires_at)
221 def inbox( 222 self, 223 *, 224 unread_only: bool = False, 225 limit: int = 20, 226 cursor: str | None = None, 227 ) -> PaginatedList[InboxMessage]: 228 """List inbox messages. 229 230 Args: 231 unread_only: If True, return only unread messages. Defaults to False. 232 limit: Maximum number of messages per page. Defaults to 20. 233 cursor: Pagination cursor from a previous response. 234 235 Returns: 236 PaginatedList[InboxMessage] supporting iteration and 237 ``.auto_paging_iter()`` for automatic pagination. 238 239 Raises: 240 MailAuthError: If the token is invalid. 241 MailNetworkError: On connection failure. 242 243 Example: 244 >>> for msg in mb.inbox(unread_only=True): 245 ... print(msg.from_address, msg.subject) 246 >>> # Auto-paginate through all messages: 247 >>> for msg in mb.inbox().auto_paging_iter(): 248 ... print(msg.id) 249 """ 250 params: dict = {"limit": limit} 251 if unread_only: 252 params["unread_only"] = "true" 253 if cursor is not None: 254 params["cursor"] = cursor 255 data = self._request("GET", "/v1/me/inbox", params=params) 256 items = [InboxMessage(**m) for m in data["items"]] 257 result = PaginatedList( 258 items=items, cursor=data.get("cursor"), has_more=data.get("has_more", False) 259 ) 260 result._fetch_fn = self.inbox 261 result._fetch_kwargs = {"unread_only": unread_only, "limit": limit} 262 return result
List inbox messages.
Args: unread_only: If True, return only unread messages. Defaults to False. limit: Maximum number of messages per page. Defaults to 20. cursor: Pagination cursor from a previous response.
Returns:
PaginatedList[InboxMessage] supporting iteration and
.auto_paging_iter() for automatic pagination.
Raises: MailAuthError: If the token is invalid. MailNetworkError: On connection failure.
Example:
for msg in mb.inbox(unread_only=True): ... print(msg.from_address, msg.subject)
Auto-paginate through all messages:
for msg in mb.inbox().auto_paging_iter(): ... print(msg.id)
264 def read(self, message_id: str) -> MessageDetail: 265 """Read a full message. 266 267 Marks the message as read on the server. 268 269 Args: 270 message_id: The message ID (from InboxMessage.id). 271 272 Returns: 273 MessageDetail with full body, headers, and auth results 274 (``spf``, ``dkim``, ``dmarc``). 275 276 Raises: 277 MailNotFoundError: If the message does not exist. 278 MailAuthError: If the token is invalid. 279 280 Example: 281 >>> detail = mb.read("msg_abc123") 282 >>> print(detail.body_text) 283 """ 284 data = self._request("GET", f"/v1/me/messages/{message_id}") 285 return MessageDetail(**data)
Read a full message.
Marks the message as read on the server.
Args: message_id: The message ID (from InboxMessage.id).
Returns:
MessageDetail with full body, headers, and auth results
(spf, dkim, dmarc).
Raises: MailNotFoundError: If the message does not exist. MailAuthError: If the token is invalid.
Example:
detail = mb.read("msg_abc123") print(detail.body_text)
287 def threads( 288 self, 289 *, 290 limit: int = 20, 291 cursor: str | None = None, 292 ) -> PaginatedList[ThreadSummary]: 293 """List email threads. 294 295 Args: 296 limit: Maximum number of threads per page. Defaults to 20. 297 cursor: Pagination cursor from a previous response. 298 299 Returns: 300 PaginatedList[ThreadSummary] supporting iteration and 301 ``.auto_paging_iter()``. 302 303 Raises: 304 MailAuthError: If the token is invalid. 305 MailNetworkError: On connection failure. 306 307 Example: 308 >>> for t in mb.threads(): 309 ... print(t.subject, t.message_count) 310 """ 311 params: dict = {"limit": limit} 312 if cursor is not None: 313 params["cursor"] = cursor 314 data = self._request("GET", "/v1/me/threads", params=params) 315 items = [ThreadSummary(**t) for t in data["items"]] 316 result = PaginatedList( 317 items=items, cursor=data.get("cursor"), has_more=data.get("has_more", False) 318 ) 319 result._fetch_fn = self.threads 320 result._fetch_kwargs = {"limit": limit} 321 return result
List email threads.
Args: limit: Maximum number of threads per page. Defaults to 20. cursor: Pagination cursor from a previous response.
Returns:
PaginatedList[ThreadSummary] supporting iteration and
.auto_paging_iter().
Raises: MailAuthError: If the token is invalid. MailNetworkError: On connection failure.
Example:
for t in mb.threads(): ... print(t.subject, t.message_count)
323 def thread(self, thread_id: str) -> ThreadDetail: 324 """Get a full thread with all messages. 325 326 Args: 327 thread_id: The thread ID. 328 329 Returns: 330 ThreadDetail containing the thread metadata and a ``messages`` 331 list of MessageDetail objects. 332 333 Raises: 334 MailNotFoundError: If the thread does not exist. 335 MailAuthError: If the token is invalid. 336 337 Example: 338 >>> t = mb.thread("thr_abc123") 339 >>> for msg in t.messages: 340 ... print(msg.from_address, msg.subject) 341 """ 342 data = self._request("GET", f"/v1/me/threads/{thread_id}") 343 messages = [MessageDetail(**m) for m in data.pop("messages", [])] 344 return ThreadDetail(**data, messages=messages)
Get a full thread with all messages.
Args: thread_id: The thread ID.
Returns:
ThreadDetail containing the thread metadata and a messages
list of MessageDetail objects.
Raises: MailNotFoundError: If the thread does not exist. MailAuthError: If the token is invalid.
Example:
t = mb.thread("thr_abc123") for msg in t.messages: ... print(msg.from_address, msg.subject)
348 def send( 349 self, 350 *, 351 to: str, 352 subject: str, 353 body_text: str, 354 body_html: str | None = None, 355 require_approval: bool = False, 356 ) -> SendResult: 357 """Send an email from this mailbox. 358 359 Args: 360 to: Recipient email address. 361 subject: Email subject line. 362 body_text: Plain-text email body. 363 body_html: Optional HTML email body. 364 require_approval: If True, the email is held for human approval 365 before sending. Defaults to False. 366 367 Returns: 368 SendResult with ``thread_id``, ``message_id``, and ``status`` 369 (``"sent"``, ``"held"``, or ``"pending_approval"``). When 370 approval is required, ``approval_url`` is set. 371 372 Raises: 373 MailValidationError: If the payload is invalid (e.g. bad address). 374 MailAuthError: If the token is invalid. 375 376 Example: 377 >>> result = mb.send( 378 ... to="user@example.com", 379 ... subject="Invoice #42", 380 ... body_text="Please find your invoice attached.", 381 ... ) 382 >>> print(result.message_id) 383 """ 384 payload: dict = {"to": to, "subject": subject, "body_text": body_text} 385 if body_html is not None: 386 payload["body_html"] = body_html 387 if require_approval: 388 payload["require_approval"] = True 389 data = self._request("POST", "/v1/me/send", json=payload) 390 return SendResult(**data)
Send an email from this mailbox.
Args: to: Recipient email address. subject: Email subject line. body_text: Plain-text email body. body_html: Optional HTML email body. require_approval: If True, the email is held for human approval before sending. Defaults to False.
Returns:
SendResult with thread_id, message_id, and status
("sent", "held", or "pending_approval"). When
approval is required, approval_url is set.
Raises: MailValidationError: If the payload is invalid (e.g. bad address). MailAuthError: If the token is invalid.
Example:
result = mb.send( ... to="user@example.com", ... subject="Invoice #42", ... body_text="Please find your invoice attached.", ... ) print(result.message_id)
392 def reply( 393 self, 394 message_id: str, 395 *, 396 body_text: str, 397 body_html: str | None = None, 398 require_approval: bool = False, 399 ) -> SendResult: 400 """Reply to a message. 401 402 Sends a reply in the same thread as the original message. 403 404 Args: 405 message_id: The ID of the message to reply to. 406 body_text: Plain-text reply body. 407 body_html: Optional HTML reply body. 408 require_approval: If True, the reply is held for human approval. 409 Defaults to False. 410 411 Returns: 412 SendResult with ``thread_id``, ``message_id``, and ``status``. 413 414 Raises: 415 MailNotFoundError: If the original message does not exist. 416 MailValidationError: If the payload is invalid. 417 MailAuthError: If the token is invalid. 418 419 Example: 420 >>> for msg in mb.inbox(unread_only=True): 421 ... mb.reply(msg.id, body_text="Got it, thanks!") 422 """ 423 payload: dict = {"body_text": body_text} 424 if body_html is not None: 425 payload["body_html"] = body_html 426 if require_approval: 427 payload["require_approval"] = True 428 data = self._request("POST", f"/v1/me/reply/{message_id}", json=payload) 429 return SendResult(**data)
Reply to a message.
Sends a reply in the same thread as the original message.
Args: message_id: The ID of the message to reply to. body_text: Plain-text reply body. body_html: Optional HTML reply body. require_approval: If True, the reply is held for human approval. Defaults to False.
Returns:
SendResult with thread_id, message_id, and status.
Raises: MailNotFoundError: If the original message does not exist. MailValidationError: If the payload is invalid. MailAuthError: If the token is invalid.
Example:
for msg in mb.inbox(unread_only=True): ... mb.reply(msg.id, body_text="Got it, thanks!")
433 def list_attachments(self, message_id: str) -> list[AttachmentInfo]: 434 """List attachments on a message. 435 436 Args: 437 message_id: The message ID. 438 439 Returns: 440 List of AttachmentInfo with ``id``, ``filename``, 441 ``content_type``, and ``size_bytes``. 442 443 Raises: 444 MailNotFoundError: If the message does not exist. 445 MailAuthError: If the token is invalid. 446 447 Example: 448 >>> attachments = mb.list_attachments("msg_abc123") 449 >>> for a in attachments: 450 ... print(a.filename, a.size_bytes) 451 """ 452 data = self._request("GET", f"/v1/me/messages/{message_id}/attachments") 453 return [AttachmentInfo(**a) for a in data["items"]]
List attachments on a message.
Args: message_id: The message ID.
Returns:
List of AttachmentInfo with id, filename,
content_type, and size_bytes.
Raises: MailNotFoundError: If the message does not exist. MailAuthError: If the token is invalid.
Example:
attachments = mb.list_attachments("msg_abc123") for a in attachments: ... print(a.filename, a.size_bytes)
455 def download_attachment(self, message_id: str, attachment_id: str) -> bytes: 456 """Download a raw attachment. 457 458 Args: 459 message_id: The message ID containing the attachment. 460 attachment_id: The attachment ID (from AttachmentInfo.id). 461 462 Returns: 463 Raw file bytes. 464 465 Raises: 466 MailNotFoundError: If the message or attachment does not exist. 467 MailAuthError: If the token is invalid. 468 MailNetworkError: On connection failure after retries. 469 470 Example: 471 >>> data = mb.download_attachment("msg_abc123", "att_def456") 472 >>> with open("invoice.pdf", "wb") as f: 473 ... f.write(data) 474 """ 475 if self._destroyed: 476 raise MailError(message="Mailbox has been destroyed.") 477 path = f"/v1/me/messages/{message_id}/attachments/{attachment_id}" 478 last_response: httpx.Response | None = None 479 last_exc: httpx.TransportError | None = None 480 for attempt in range(self._max_retries + 1): 481 try: 482 response = self._client.request("GET", path) 483 except (httpx.TimeoutException, httpx.ConnectError) as exc: 484 last_exc = exc 485 if attempt < self._max_retries: 486 _time.sleep(_transport_retry_delay(attempt)) 487 continue 488 raise MailNetworkError( 489 message=f"{type(exc).__name__}: {exc}", 490 ) from exc 491 except httpx.TransportError as exc: 492 raise MailNetworkError( 493 message=f"{type(exc).__name__}: {exc}", 494 ) from exc 495 last_exc = None 496 last_response = response 497 if response.status_code in RETRY_STATUS_CODES and attempt < self._max_retries: 498 _time.sleep(_retry_delay(response, attempt)) 499 continue 500 break 501 if last_response is None: 502 raise MailNetworkError( 503 message=f"{type(last_exc).__name__}: {last_exc}", 504 ) from last_exc 505 raise_for_status( 506 last_response, 507 exc_map=_EXC_MAP, 508 server_exc=MailServerError, 509 base_exc=MailError, 510 ) 511 return last_response.content
Download a raw attachment.
Args: message_id: The message ID containing the attachment. attachment_id: The attachment ID (from AttachmentInfo.id).
Returns: Raw file bytes.
Raises: MailNotFoundError: If the message or attachment does not exist. MailAuthError: If the token is invalid. MailNetworkError: On connection failure after retries.
Example:
data = mb.download_attachment("msg_abc123", "att_def456") with open("invoice.pdf", "wb") as f: ... f.write(data)
515 def get_allowlist(self) -> list[AllowlistEntry]: 516 """List allowed senders. 517 518 Returns: 519 List of AllowlistEntry with ``id``, ``sender_address``, and 520 ``created_at``. 521 522 Raises: 523 MailAuthError: If the token is invalid. 524 525 Example: 526 >>> for entry in mb.get_allowlist(): 527 ... print(entry.sender_address) 528 """ 529 data = self._request("GET", "/v1/me/allowlist") 530 return [AllowlistEntry(**e) for e in data["items"]]
List allowed senders.
Returns:
List of AllowlistEntry with id, sender_address, and
created_at.
Raises: MailAuthError: If the token is invalid.
Example:
for entry in mb.get_allowlist(): ... print(entry.sender_address)
532 def add_allowlist(self, sender_address: str) -> AllowlistEntry: 533 """Add an allowed sender. 534 535 Only emails from allowlisted senders will be delivered to this 536 mailbox (when the allowlist is non-empty). 537 538 Args: 539 sender_address: Email address to allow (e.g. ``"noreply@github.com"``). 540 541 Returns: 542 The created AllowlistEntry. 543 544 Raises: 545 MailConflictError: If the address is already allowlisted. 546 MailValidationError: If the address is invalid. 547 MailAuthError: If the token is invalid. 548 549 Example: 550 >>> entry = mb.add_allowlist("noreply@github.com") 551 >>> print(entry.id) 552 """ 553 data = self._request( 554 "POST", "/v1/me/allowlist", json={"sender_address": sender_address} 555 ) 556 return AllowlistEntry(**data)
Add an allowed sender.
Only emails from allowlisted senders will be delivered to this mailbox (when the allowlist is non-empty).
Args:
sender_address: Email address to allow (e.g. "noreply@github.com").
Returns: The created AllowlistEntry.
Raises: MailConflictError: If the address is already allowlisted. MailValidationError: If the address is invalid. MailAuthError: If the token is invalid.
Example:
entry = mb.add_allowlist("noreply@github.com") print(entry.id)
558 def remove_allowlist(self, entry_id: str) -> None: 559 """Remove an allowed sender. 560 561 Args: 562 entry_id: The allowlist entry ID (from AllowlistEntry.id). 563 564 Raises: 565 MailNotFoundError: If the entry does not exist. 566 MailAuthError: If the token is invalid. 567 568 Example: 569 >>> mb.remove_allowlist("ale_abc123") 570 """ 571 self._request("DELETE", f"/v1/me/allowlist/{entry_id}")
Remove an allowed sender.
Args: entry_id: The allowlist entry ID (from AllowlistEntry.id).
Raises: MailNotFoundError: If the entry does not exist. MailAuthError: If the token is invalid.
Example:
mb.remove_allowlist("ale_abc123")
575 def get_auth_policy(self) -> str: 576 """Get the email authentication policy. 577 578 Returns: 579 The current policy as a string: ``"none"``, ``"warn"``, or 580 ``"reject"``. 581 582 Raises: 583 MailAuthError: If the token is invalid. 584 585 Example: 586 >>> policy = mb.get_auth_policy() 587 >>> print(policy) # "none", "warn", or "reject" 588 """ 589 data = self._request("GET", "/v1/me/auth-policy") 590 return data["auth_policy"]
Get the email authentication policy.
Returns:
The current policy as a string: "none", "warn", or
"reject".
Raises: MailAuthError: If the token is invalid.
Example:
policy = mb.get_auth_policy() print(policy) # "none", "warn", or "reject"
592 def set_auth_policy(self, policy: Literal["none", "warn", "reject"]) -> None: 593 """Set the email authentication policy. 594 595 Controls how the mailbox handles emails that fail SPF/DKIM/DMARC 596 checks. 597 598 Args: 599 policy: One of ``"none"`` (accept all), ``"warn"`` (accept but 600 flag), or ``"reject"`` (reject failing emails). 601 602 Raises: 603 MailValidationError: If the policy value is invalid. 604 MailAuthError: If the token is invalid. 605 606 Example: 607 >>> mb.set_auth_policy("reject") 608 """ 609 self._request("PUT", "/v1/me/auth-policy", json={"auth_policy": policy})
Set the email authentication policy.
Controls how the mailbox handles emails that fail SPF/DKIM/DMARC checks.
Args:
policy: One of "none" (accept all), "warn" (accept but
flag), or "reject" (reject failing emails).
Raises: MailValidationError: If the policy value is invalid. MailAuthError: If the token is invalid.
Example:
mb.set_auth_policy("reject")
613 def set_webhook(self, url: str, events: list[str] | None = None) -> WebhookCreated: 614 """Register a webhook. Returns WebhookCreated with signing secret (shown once). 615 616 Replaces any existing webhook on this mailbox. 617 618 Args: 619 url: The HTTPS URL to receive webhook POST requests. 620 events: List of event types to subscribe to (e.g. 621 ``["message.received"]``). Omit to subscribe to all events. 622 623 Returns: 624 WebhookCreated with ``id``, ``url``, ``secret`` (one-time), 625 and ``events``. 626 627 Raises: 628 MailValidationError: If the URL is invalid. 629 MailAuthError: If the token is invalid. 630 631 Example: 632 >>> wh = mb.set_webhook("https://example.com/hook") 633 >>> print(wh.secret) # save this -- shown only once 634 """ 635 payload: dict = {"url": url} 636 if events is not None: 637 payload["events"] = events 638 data = self._request("POST", "/v1/me/webhook", json=payload) 639 return WebhookCreated(**data)
Register a webhook. Returns WebhookCreated with signing secret (shown once).
Replaces any existing webhook on this mailbox.
Args:
url: The HTTPS URL to receive webhook POST requests.
events: List of event types to subscribe to (e.g.
["message.received"]). Omit to subscribe to all events.
Returns:
WebhookCreated with id, url, secret (one-time),
and events.
Raises: MailValidationError: If the URL is invalid. MailAuthError: If the token is invalid.
Example:
wh = mb.set_webhook("https://example.com/hook") print(wh.secret) # save this -- shown only once
641 def webhook(self) -> WebhookInfo: 642 """Get current webhook config. 643 644 Returns: 645 WebhookInfo with ``id``, ``url``, ``events``, and ``created_at``. 646 647 Raises: 648 MailNotFoundError: If no webhook is configured. 649 MailAuthError: If the token is invalid. 650 651 Example: 652 >>> wh = mb.webhook() 653 >>> print(wh.url, wh.events) 654 """ 655 data = self._request("GET", "/v1/me/webhook") 656 return WebhookInfo(**data)
Get current webhook config.
Returns:
WebhookInfo with id, url, events, and created_at.
Raises: MailNotFoundError: If no webhook is configured. MailAuthError: If the token is invalid.
Example:
wh = mb.webhook() print(wh.url, wh.events)
658 def delete_webhook(self) -> None: 659 """Remove the webhook. 660 661 Raises: 662 MailNotFoundError: If no webhook is configured. 663 MailAuthError: If the token is invalid. 664 665 Example: 666 >>> mb.delete_webhook() 667 """ 668 self._request("DELETE", "/v1/me/webhook")
Remove the webhook.
Raises: MailNotFoundError: If no webhook is configured. MailAuthError: If the token is invalid.
Example:
mb.delete_webhook()
672 def delete(self) -> None: 673 """Delete this mailbox from the server and close the HTTP client. 674 675 This permanently destroys the mailbox and all its messages. The 676 Mailbox instance cannot be used after this call. 677 678 Raises: 679 MailAuthError: If the token is invalid. 680 MailNetworkError: On connection failure. 681 682 Example: 683 >>> mb.delete() # mailbox is gone 684 """ 685 self._request("DELETE", "/v1/me") 686 self._destroyed = True 687 if self._owns_client: 688 self._client.close()
Delete this mailbox from the server and close the HTTP client.
This permanently destroys the mailbox and all its messages. The Mailbox instance cannot be used after this call.
Raises: MailAuthError: If the token is invalid. MailNetworkError: On connection failure.
Example:
mb.delete() # mailbox is gone
690 def close(self) -> None: 691 """Close the HTTP client. 692 693 The mailbox remains active on the server. Use ``delete()`` to 694 destroy it entirely. Called automatically when used as a context 695 manager. 696 697 Example: 698 >>> mb.close() 699 """ 700 if self._owns_client: 701 self._client.close()
Close the HTTP client.
The mailbox remains active on the server. Use delete() to
destroy it entirely. Called automatically when used as a context
manager.
Example:
mb.close()
13class PaginatedList(BaseModel, Generic[T]): 14 """Paginated list response. Iterable — ``for msg in inbox:`` works directly. 15 16 Supports automatic pagination:: 17 18 for msg in mb.inbox().auto_paging_iter(): 19 print(msg.subject) 20 """ 21 22 items: list[T] 23 cursor: str | None = None 24 has_more: bool = False 25 26 _fetch_fn: Callable[..., PaginatedList[T]] | None = PrivateAttr(default=None) 27 _fetch_kwargs: dict[str, Any] = PrivateAttr(default_factory=dict) 28 29 def __iter__(self) -> Iterator[T]: 30 return iter(self.items) 31 32 def __getitem__(self, index: int) -> T: 33 return self.items[index] 34 35 def __len__(self) -> int: 36 return len(self.items) 37 38 def __bool__(self) -> bool: 39 return bool(self.items) 40 41 def auto_paging_iter(self, *, max_pages: int = 1000) -> Iterator[T]: 42 """Iterate through all pages automatically. 43 44 Args: 45 max_pages: Safety limit on total pages fetched (default 1000). 46 """ 47 page = self 48 for _ in range(max_pages): 49 yield from page.items 50 if not page.has_more or page.cursor is None or page._fetch_fn is None: 51 break 52 page = page._fetch_fn(**{**page._fetch_kwargs, "cursor": page.cursor})
Paginated list response. Iterable — for msg in inbox: works directly.
Supports automatic pagination::
for msg in mb.inbox().auto_paging_iter():
print(msg.subject)
41 def auto_paging_iter(self, *, max_pages: int = 1000) -> Iterator[T]: 42 """Iterate through all pages automatically. 43 44 Args: 45 max_pages: Safety limit on total pages fetched (default 1000). 46 """ 47 page = self 48 for _ in range(max_pages): 49 yield from page.items 50 if not page.has_more or page.cursor is None or page._fetch_fn is None: 51 break 52 page = page._fetch_fn(**{**page._fetch_kwargs, "cursor": page.cursor})
Iterate through all pages automatically.
Args: max_pages: Safety limit on total pages fetched (default 1000).
14def verify_webhook( 15 *, 16 secret: str, 17 signature: str, 18 body: str | bytes, 19 timestamp: str | None = None, 20 tolerance: int = 300, 21) -> dict: 22 """Verify a webhook payload and return the parsed data. 23 24 Works for both Mail webhooks (with timestamp) and Approval webhooks 25 (without). Uses HMAC-SHA256 with timing-safe comparison. 26 27 Args: 28 secret: The webhook signing secret (from ``WebhookCreated.secret`` 29 or the Primitif console). 30 signature: Value of the signature header (``X-Webhook-Signature``). 31 May optionally include a ``sha256=`` prefix. 32 body: Raw request body (str or bytes). 33 timestamp: Value of the timestamp header (unix seconds). Required 34 for Mail webhooks, optional for Approval webhooks. 35 tolerance: Max age in seconds. Defaults to 300 (5 min). Only 36 checked when ``timestamp`` is provided. Set to 0 to disable 37 replay protection. 38 39 Returns: 40 Parsed JSON payload as a dict. 41 42 Raises: 43 InvalidSignature: If the signature does not match, the timestamp 44 is missing/malformed, or the payload is too old. 45 46 Example: 47 >>> from primitif import verify_webhook 48 >>> event = verify_webhook( 49 ... secret="whsec_abc123...", 50 ... signature=request.headers["X-Webhook-Signature"], 51 ... body=request.data, 52 ... timestamp=request.headers.get("X-Webhook-Timestamp"), 53 ... ) 54 >>> print(event["type"], event["data"]) 55 """ 56 if not signature: 57 raise InvalidSignature("Missing signature") 58 if isinstance(body, bytes): 59 body = body.decode("utf-8") 60 61 # Replay protection (only when timestamp provided) 62 if timestamp is not None and tolerance > 0: 63 try: 64 ts = int(timestamp) 65 except (ValueError, TypeError): 66 raise InvalidSignature("Invalid timestamp") from None 67 if abs(_time.time() - ts) > tolerance: 68 raise InvalidSignature("Timestamp too old") 69 70 # Build the signed message 71 if timestamp is not None: 72 message = f"{timestamp}.{body}" 73 else: 74 message = body 75 76 expected = hmac.new( 77 secret.encode(), message.encode(), hashlib.sha256 78 ).hexdigest() 79 80 # Strip sha256= prefix if present 81 sig = signature.removeprefix("sha256=") 82 83 if not hmac.compare_digest(expected, sig): 84 raise InvalidSignature("Signature mismatch") 85 86 return json.loads(body)
Verify a webhook payload and return the parsed data.
Works for both Mail webhooks (with timestamp) and Approval webhooks (without). Uses HMAC-SHA256 with timing-safe comparison.
Args:
secret: The webhook signing secret (from WebhookCreated.secret
or the Primitif console).
signature: Value of the signature header (X-Webhook-Signature).
May optionally include a sha256= prefix.
body: Raw request body (str or bytes).
timestamp: Value of the timestamp header (unix seconds). Required
for Mail webhooks, optional for Approval webhooks.
tolerance: Max age in seconds. Defaults to 300 (5 min). Only
checked when timestamp is provided. Set to 0 to disable
replay protection.
Returns: Parsed JSON payload as a dict.
Raises: InvalidSignature: If the signature does not match, the timestamp is missing/malformed, or the payload is too old.
Example:
from primitif import verify_webhook event = verify_webhook( ... secret="whsec_abc123...", ... signature=request.headers["X-Webhook-Signature"], ... body=request.data, ... timestamp=request.headers.get("X-Webhook-Timestamp"), ... ) print(event["type"], event["data"])
12class PrimitifError(Exception): 13 """Base exception for all Primitif SDK errors.""" 14 15 def __init__( 16 self, 17 message: str = "", 18 *, 19 status_code: int = 0, 20 code: str = "", 21 response: "httpx.Response | None" = None, 22 request_id: str | None = None, 23 ) -> None: 24 self.message = message 25 self.status_code = status_code 26 self.code = code 27 self.response = response 28 self.request_id = request_id 29 if status_code: 30 super().__init__(f"{status_code} {code}: {message}") 31 else: 32 super().__init__(message)
Base exception for all Primitif SDK errors.
15 def __init__( 16 self, 17 message: str = "", 18 *, 19 status_code: int = 0, 20 code: str = "", 21 response: "httpx.Response | None" = None, 22 request_id: str | None = None, 23 ) -> None: 24 self.message = message 25 self.status_code = status_code 26 self.code = code 27 self.response = response 28 self.request_id = request_id 29 if status_code: 30 super().__init__(f"{status_code} {code}: {message}") 31 else: 32 super().__init__(message)
401 — invalid or missing credentials.
404 — resource not found.
409 — resource conflict.
422 — invalid request payload.
51class RateLimitError(PrimitifError): 52 """429 — rate limit exceeded.""" 53 54 @property 55 def retry_after(self) -> float | None: 56 """Seconds to wait before retrying, from the Retry-After header.""" 57 if self.response is not None: 58 val = self.response.headers.get("retry-after") 59 if val: 60 try: 61 return float(val) 62 except (ValueError, TypeError): 63 return None 64 return None
429 — rate limit exceeded.
54 @property 55 def retry_after(self) -> float | None: 56 """Seconds to wait before retrying, from the Retry-After header.""" 57 if self.response is not None: 58 val = self.response.headers.get("retry-after") 59 if val: 60 try: 61 return float(val) 62 except (ValueError, TypeError): 63 return None 64 return None
Seconds to wait before retrying, from the Retry-After header.
5xx — server error.
71class NetworkError(PrimitifError): 72 """Network-level failure — timeout, DNS, TCP, or TLS error."""
Network-level failure — timeout, DNS, TCP, or TLS error.
Webhook signature verification failed.
23def require_approval( 24 fn: Callable | None = None, 25 *, 26 action: str | None = None, 27 requested_by: str | None = None, 28 callback_url: str | None = None, 29) -> Callable: 30 """Standalone decorator that gates a function behind human approval. 31 32 Reads ``PRIMITIF_API_KEY`` from the environment. No client setup needed. 33 Creates a new Primitif client on each call. 34 35 Can be used with or without parentheses: ``@require_approval`` or 36 ``@require_approval(action="Deploy")``. 37 38 Args: 39 fn: The function to wrap. Passed automatically when used as 40 ``@require_approval`` without parentheses. Do not pass explicitly. 41 action: Human-readable action name shown to the approver. 42 Defaults to the function name with underscores replaced by 43 spaces (``send_invoice`` -> ``"Send invoice"``). 44 requested_by: Optional agent or service identifier attached to the 45 approval request. Defaults to None. 46 callback_url: Optional webhook URL for direct callback when the 47 request is decided. Defaults to None. 48 49 Returns: 50 The decorated function. Each call returns an ApprovalRequest 51 (status ``"pending"``) instead of executing the original function. 52 The original is accessible via ``wrapped.original``. 53 54 Raises: 55 PrimitifError: If ``PRIMITIF_API_KEY`` is not set. 56 57 Example: 58 >>> from primitif.approval import require_approval 59 >>> @require_approval 60 ... def deploy(service, branch, env): 61 ... run_pipeline(service, branch, env) 62 >>> req = deploy("api-gateway", "main", "production") 63 >>> print(req.approval_url) # share with the human 64 65 >>> @require_approval(requested_by="deploy-agent") 66 ... def deploy(service, branch, env): 67 ... run_pipeline(service, branch, env) 68 """ 69 70 def decorator(fn: Callable) -> Callable: 71 resolved_action = action or fn.__name__.replace("_", " ").capitalize() 72 73 @functools.wraps(fn) 74 def wrapper(*args: Any, **kwargs: Any) -> ApprovalRequest: 75 from primitif.client import Primitif 76 77 p = Primitif() 78 sig = inspect.signature(fn) 79 bound = sig.bind(*args, **kwargs) 80 bound.apply_defaults() 81 context = dict(bound.arguments) 82 83 return p.approval.create_request( 84 resolved_action, 85 context=context, 86 requested_by=requested_by, 87 callback_url=callback_url, 88 ) 89 90 wrapper.original = fn # type: ignore[attr-defined] 91 return wrapper 92 93 # Support both @require_approval and @require_approval(...) 94 if fn is not None: 95 return decorator(fn) 96 return decorator
Standalone decorator that gates a function behind human approval.
Reads PRIMITIF_API_KEY from the environment. No client setup needed.
Creates a new Primitif client on each call.
Can be used with or without parentheses: @require_approval or
@require_approval(action="Deploy").
Args:
fn: The function to wrap. Passed automatically when used as
@require_approval without parentheses. Do not pass explicitly.
action: Human-readable action name shown to the approver.
Defaults to the function name with underscores replaced by
spaces (send_invoice -> "Send invoice").
requested_by: Optional agent or service identifier attached to the
approval request. Defaults to None.
callback_url: Optional webhook URL for direct callback when the
request is decided. Defaults to None.
Returns:
The decorated function. Each call returns an ApprovalRequest
(status "pending") instead of executing the original function.
The original is accessible via wrapped.original.
Raises:
PrimitifError: If PRIMITIF_API_KEY is not set.
Example:
from primitif.approval import require_approval @require_approval ... def deploy(service, branch, env): ... run_pipeline(service, branch, env) req = deploy("api-gateway", "main", "production") print(req.approval_url) # share with the human
>>> @require_approval(requested_by="deploy-agent") ... def deploy(service, branch, env): ... run_pipeline(service, branch, env)