puffin-local 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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Puffin
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,81 @@
1
+ Metadata-Version: 2.4
2
+ Name: puffin-local
3
+ Version: 0.3.0
4
+ Summary: Puffin SDK, CLI and MCP server for AI agent identity and communications
5
+ Home-page: https://www.justpuffin.tech
6
+ Author: Puffin
7
+ License: MIT
8
+ Keywords: puffin,ai agents,email,sms,mcp,cli
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: requests
15
+ Requires-Dist: cryptography<51,>=50
16
+ Provides-Extra: mcp
17
+ Requires-Dist: mcp<2,>=1.12; extra == "mcp"
18
+ Provides-Extra: tunnel
19
+ Requires-Dist: websockets<17,>=15; extra == "tunnel"
20
+ Dynamic: author
21
+ Dynamic: classifier
22
+ Dynamic: description
23
+ Dynamic: description-content-type
24
+ Dynamic: home-page
25
+ Dynamic: keywords
26
+ Dynamic: license
27
+ Dynamic: license-file
28
+ Dynamic: provides-extra
29
+ Dynamic: requires-dist
30
+ Dynamic: requires-python
31
+ Dynamic: summary
32
+
33
+ # puffin-local
34
+
35
+ Python SDK, `puffin` command line and local MCP server for [Puffin](https://www.justpuffin.tech), the identity and communications layer for AI agents.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pip install puffin-local
41
+ # With the local MCP server and tunnel worker:
42
+ pip install "puffin-local[mcp,tunnel]"
43
+ ```
44
+
45
+ ## Configure
46
+
47
+ Create an agent and a scoped agent key in the Puffin dashboard, then set:
48
+
49
+ ```bash
50
+ export PUFFIN_API_URL=https://puffin-api.onrender.com
51
+ export PUFFIN_API_KEY=pf_... # keep this secret
52
+ export PUFFIN_IDENTITY_ID=<agent id>
53
+ ```
54
+
55
+ ## Python
56
+
57
+ ```python
58
+ from puffin import Puffin
59
+
60
+ client = Puffin(api_key, "https://puffin-api.onrender.com")
61
+ agent = client.agent(identity_id)
62
+ print(agent.identity())
63
+ agent.send("someone@example.com", "Hello", "Subject", transport="resend") # waits for approval
64
+ print(agent.search("invoice"))
65
+ ```
66
+
67
+ ## Command line
68
+
69
+ ```bash
70
+ puffin identity
71
+ puffin email search invoice
72
+ puffin email draft --to someone@example.com --subject Hi --body "Draft text"
73
+ puffin contacts list
74
+ puffin events watch
75
+ ```
76
+
77
+ Every outgoing message waits for a workspace administrator's approval.
78
+
79
+ ## MCP
80
+
81
+ `puffin-mcp` runs a local stdio MCP server with the same tools as the hosted endpoint (`<API URL>/mcp`).
@@ -0,0 +1,49 @@
1
+ # puffin-local
2
+
3
+ Python SDK, `puffin` command line and local MCP server for [Puffin](https://www.justpuffin.tech), the identity and communications layer for AI agents.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install puffin-local
9
+ # With the local MCP server and tunnel worker:
10
+ pip install "puffin-local[mcp,tunnel]"
11
+ ```
12
+
13
+ ## Configure
14
+
15
+ Create an agent and a scoped agent key in the Puffin dashboard, then set:
16
+
17
+ ```bash
18
+ export PUFFIN_API_URL=https://puffin-api.onrender.com
19
+ export PUFFIN_API_KEY=pf_... # keep this secret
20
+ export PUFFIN_IDENTITY_ID=<agent id>
21
+ ```
22
+
23
+ ## Python
24
+
25
+ ```python
26
+ from puffin import Puffin
27
+
28
+ client = Puffin(api_key, "https://puffin-api.onrender.com")
29
+ agent = client.agent(identity_id)
30
+ print(agent.identity())
31
+ agent.send("someone@example.com", "Hello", "Subject", transport="resend") # waits for approval
32
+ print(agent.search("invoice"))
33
+ ```
34
+
35
+ ## Command line
36
+
37
+ ```bash
38
+ puffin identity
39
+ puffin email search invoice
40
+ puffin email draft --to someone@example.com --subject Hi --body "Draft text"
41
+ puffin contacts list
42
+ puffin events watch
43
+ ```
44
+
45
+ Every outgoing message waits for a workspace administrator's approval.
46
+
47
+ ## MCP
48
+
49
+ `puffin-mcp` runs a local stdio MCP server with the same tools as the hosted endpoint (`<API URL>/mcp`).
@@ -0,0 +1,4 @@
1
+ from .client import Puffin, Agent, PuffinError
2
+ from .crypto import encrypt, decrypt, totp
3
+
4
+ __all__ = ["Puffin", "Agent", "PuffinError", "encrypt", "decrypt", "totp"]
@@ -0,0 +1,125 @@
1
+ """`puffin` command line. Tokens come only from the environment; sends ask for confirmation.
2
+
3
+ Exit codes: 0 success, 1 API error, 2 usage or configuration, 3 cancelled at a prompt, 4 local error.
4
+ """
5
+ import json
6
+ import os
7
+ import sys
8
+ from .client import Puffin, PuffinError
9
+ from .commands import build_parser, LocalError
10
+
11
+
12
+ class Cancelled(Exception):
13
+ pass
14
+
15
+
16
+ def scalar(value):
17
+ text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
18
+ text = " ".join(text.split())
19
+ return text if len(text) <= 80 else text[:77] + "..."
20
+
21
+
22
+ def render(data):
23
+ """Human-readable output; pass --json for the exact API response."""
24
+ if data is None:
25
+ return "ok"
26
+ if isinstance(data, dict):
27
+ return "\n".join(f"{key}: {scalar(value)}" for key, value in data.items()) or "{}"
28
+ if isinstance(data, list):
29
+ if not data:
30
+ return "(none)"
31
+ return "\n".join("- " + (" ".join(f"{key}={scalar(value)}" for key, value in item.items() if not isinstance(value, (dict, list))) or scalar(item))
32
+ if isinstance(item, dict) else "- " + scalar(item) for item in data)
33
+ return str(data)
34
+
35
+
36
+ class Context:
37
+ """What handlers may use: a lazily built client, confirmation, output and the tunnel."""
38
+
39
+ def __init__(self, args, token):
40
+ self.args = args
41
+ self.url = args.url
42
+ self.token = token
43
+ self._client = None
44
+
45
+ @property
46
+ def puffin(self):
47
+ # Looked up at call time so tests can monkeypatch puffin.cli.Puffin.
48
+ return Puffin
49
+
50
+ @property
51
+ def client(self):
52
+ if self._client is None:
53
+ if not self.token:
54
+ raise LocalError("Set PUFFIN_API_KEY; tokens are never accepted on the command line")
55
+ self._client = Puffin(self.token, self.url)
56
+ return self._client
57
+
58
+ def agent(self):
59
+ return self.client.agent(self.args.identity)
60
+
61
+ def confirm(self, summary):
62
+ if self.args.yes:
63
+ return
64
+ try:
65
+ answer = input(f"{summary}? [y/N] ")
66
+ except EOFError:
67
+ answer = ""
68
+ if answer.strip().lower() not in {"y", "yes"}:
69
+ raise Cancelled()
70
+
71
+ def emit(self, data, stream=False):
72
+ if self.args.json:
73
+ print(json.dumps(data, ensure_ascii=False, indent=None if stream else 2), flush=True)
74
+ else:
75
+ print(render(data), flush=True)
76
+
77
+ def tunnel(self, argv):
78
+ from . import tunnel # optional websockets dependency; imported only when used
79
+ return tunnel.main(argv)
80
+
81
+ def close(self):
82
+ if self._client is not None:
83
+ self._client.close()
84
+
85
+
86
+ def main(argv=None):
87
+ parser = build_parser()
88
+ argv = list(sys.argv[1:] if argv is None else argv)
89
+ args, extra = parser.parse_known_args(argv)
90
+ if args.command == "tunnel":
91
+ # Everything after the subcommand belongs to puffin-tunnel, including its options.
92
+ args.args = argv[argv.index("tunnel") + 1:]
93
+ elif extra:
94
+ parser.error("unrecognized arguments: " + " ".join(extra))
95
+ token = os.getenv("PUFFIN_API_KEY")
96
+ if args.needs_key and not token:
97
+ parser.error("Set PUFFIN_API_KEY; tokens are never accepted on the command line")
98
+ if args.needs_identity and not args.identity:
99
+ parser.error("Set PUFFIN_IDENTITY_ID or --identity")
100
+ ctx = Context(args, token)
101
+ try:
102
+ result = args.handler(ctx, args)
103
+ if result is not None:
104
+ ctx.emit(result)
105
+ return 0
106
+ except PuffinError as exc:
107
+ print(str(exc), file=sys.stderr)
108
+ return 1
109
+ except Cancelled:
110
+ print("Cancelled; nothing was sent.", file=sys.stderr)
111
+ return 3
112
+ except LocalError as exc:
113
+ print(f"Error: {exc}", file=sys.stderr)
114
+ return 4
115
+ except ValueError as exc: # invalid UUID or base URL
116
+ print(f"Error: {exc}", file=sys.stderr)
117
+ return 2
118
+ except KeyboardInterrupt:
119
+ return 130
120
+ finally:
121
+ ctx.close()
122
+
123
+
124
+ if __name__ == "__main__":
125
+ sys.exit(main())
@@ -0,0 +1,282 @@
1
+ import time
2
+ import uuid
3
+ import re
4
+ from urllib.parse import urlparse
5
+ import requests
6
+
7
+ class PuffinError(RuntimeError):
8
+ def __init__(self, status, detail):
9
+ self.status = status
10
+ super().__init__(f"Puffin {status}: {detail}")
11
+
12
+ def check_base_url(base_url):
13
+ parsed = urlparse(base_url)
14
+ if parsed.scheme != "https" and not (parsed.scheme == "http" and parsed.hostname in {"localhost", "127.0.0.1", "::1"}):
15
+ raise ValueError("Use HTTPS except for local loopback servers")
16
+ if parsed.username or parsed.password or parsed.query or parsed.fragment:
17
+ raise ValueError("Base URL must not contain credentials, query or fragment")
18
+ return base_url.rstrip("/")
19
+
20
+ def parse(response):
21
+ if not 200 <= response.status_code < 300:
22
+ try:
23
+ detail = response.json().get("detail", "Request failed")
24
+ except (ValueError, AttributeError):
25
+ detail = "Invalid response"
26
+ raise PuffinError(response.status_code, detail)
27
+ return response.json() if response.content else None
28
+
29
+ def anonymous(method, base_url, path, data=None, headers=None):
30
+ """Unauthenticated call for self-signup. It never attaches an API key."""
31
+ response = requests.request(method, check_base_url(base_url) + path, json=data, headers=headers, timeout=30, allow_redirects=False)
32
+ return parse(response)
33
+
34
+ def uid(value):
35
+ return str(uuid.UUID(str(value)))
36
+
37
+ def call_sid(value):
38
+ """Call IDs are provider call SIDs such as CA followed by 32 hex characters, not UUIDs."""
39
+ if not re.fullmatch(r"CA[0-9a-fA-F]{32}", str(value)):
40
+ raise ValueError("Invalid call ID")
41
+ return str(value)
42
+
43
+ def handle_path(handle):
44
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,99}", handle or ""):
45
+ raise ValueError("Invalid agent handle")
46
+ return handle
47
+
48
+ class Puffin:
49
+ def __init__(self, api_key, base_url="http://127.0.0.1:8000", organization_id=None):
50
+ self.base_url = check_base_url(base_url)
51
+ self.session = requests.Session()
52
+ self.session.headers["Authorization"] = "Bearer " + api_key
53
+ if organization_id:
54
+ self.session.headers["X-Puffin-Organization"] = organization_id
55
+
56
+ def request(self, method, path, data=None, params=None, headers=None):
57
+ response = self.session.request(method, self.base_url + path, json=data, params=params, headers=headers, timeout=30, allow_redirects=False)
58
+ return parse(response)
59
+
60
+ def capabilities(self):
61
+ return self.request("GET", "/v1/capabilities")
62
+
63
+ def identities(self):
64
+ return self.request("GET", "/v1/identities")
65
+
66
+ def create_identity(self, handle, name, description=None):
67
+ return self.request("POST", "/v1/identities", {"handle": handle, "name": name, "description": description})
68
+
69
+ def agent(self, identity_id):
70
+ return Agent(identity_id, client=self)
71
+
72
+ def billing(self):
73
+ return self.request("GET", "/v1/billing")
74
+
75
+ def checkout(self, plan):
76
+ return self.request("POST", "/v1/billing/checkout", {"plan": plan})
77
+
78
+ def phone_numbers(self):
79
+ return self.request("GET", "/v1/phone/numbers")
80
+
81
+ def phone_availability(self, country, area_code=None, contains=None):
82
+ params = {"country": country, **({"area_code": area_code} if area_code else {}), **({"contains": contains} if contains else {})}
83
+ return self.request("GET", "/v1/phone/availability", params=params)
84
+
85
+ @staticmethod
86
+ def signup(base_url, code, handle, name, contact_email, description=None):
87
+ """Request an agent identity with an invitation code. Keep the returned status token; it is not an API key."""
88
+ return anonymous("POST", base_url, "/v1/signup", {"code": code, "handle": handle, "name": name, "description": description, "contact_email": contact_email})
89
+
90
+ @staticmethod
91
+ def signup_verify(base_url, signup_id, status_token, code):
92
+ return anonymous("POST", base_url, f"/v1/signup/{uid(signup_id)}/verify", {"code": code}, headers={"X-Puffin-Signup-Token": status_token})
93
+
94
+ @staticmethod
95
+ def signup_resend(base_url, signup_id, status_token):
96
+ return anonymous("POST", base_url, f"/v1/signup/{uid(signup_id)}/resend", {}, headers={"X-Puffin-Signup-Token": status_token})
97
+
98
+ @staticmethod
99
+ def signup_status(base_url, signup_id, status_token):
100
+ return anonymous("GET", base_url, f"/v1/signup/{uid(signup_id)}/status", headers={"X-Puffin-Signup-Token": status_token})
101
+
102
+ @staticmethod
103
+ def a2a_card(base_url, handle, token=None):
104
+ """Fetch a peer agent's card. Discoverable cards need no credential; others need the receiver-issued peer token."""
105
+ return anonymous("GET", base_url, "/a2a/" + handle_path(handle) + "/card", headers={"Authorization": "Bearer " + token} if token else None)
106
+
107
+ @staticmethod
108
+ def a2a_send(base_url, handle, text, token, message_id=None, context_id=None):
109
+ """Send a text task to a peer (A2A 1.0 over JSON-RPC) using its receiver-issued peer token, never an agent key."""
110
+ message = {"role": "ROLE_USER", "parts": [{"text": text}], "messageId": message_id or str(uuid.uuid4()), **({"contextId": uid(context_id)} if context_id else {})}
111
+ envelope = anonymous("POST", base_url, "/a2a/" + handle_path(handle), {"jsonrpc": "2.0", "id": 1, "method": "SendMessage", "params": {"message": message, "configuration": {"returnImmediately": True}}},
112
+ headers={"Authorization": "Bearer " + token, "A2A-Version": "1.0"})
113
+ if "error" in envelope:
114
+ raise PuffinError(422, f"A2A {envelope['error'].get('code')}: {envelope['error'].get('message')}")
115
+ return envelope.get("result")
116
+
117
+ def close(self):
118
+ self.session.close()
119
+
120
+ class Agent:
121
+ def __init__(self, identity_id, api_key=None, base_url="http://127.0.0.1:8000", client=None):
122
+ self.id = uid(identity_id)
123
+ self.client = client or Puffin(api_key, base_url)
124
+ self.path = f"/v1/identities/{self.id}"
125
+ self.mailbox = Mailbox(self)
126
+
127
+ def identity(self):
128
+ return self.client.request("GET", self.path)
129
+
130
+ def send(self, recipient, body, subject="", channel="email", idempotency_key=None, in_reply_to=None, transport="simulated", attachments=None):
131
+ """Request a send; real email always needs administrator approval."""
132
+ return self.client.request("POST", self.path + "/messages", {"recipient": recipient, "subject": subject, "body": body, "channel": channel, "in_reply_to": in_reply_to, "transport": transport, "attachments": attachments or []},
133
+ headers={"Idempotency-Key": idempotency_key or str(uuid.uuid4())})
134
+
135
+ def messages(self, after=None, limit=100):
136
+ return self.client.request("GET", self.path + "/messages", params={"limit": limit, **({"after": after} if after else {})})
137
+
138
+ def search(self, q, **filters):
139
+ """Full-text search; filters are channel, direction and limit."""
140
+ return self.client.request("GET", self.path + "/messages/search", params={"q": q, **{k: v for k, v in filters.items() if v is not None}})
141
+
142
+ def threads(self):
143
+ return self.client.request("GET", self.path + "/threads")
144
+
145
+ def thread(self, thread_id):
146
+ return self.client.request("GET", self.path + "/threads/" + uid(thread_id))
147
+
148
+ def import_mail(self, filename, content_base64):
149
+ """Import an .eml or .mbox export (base64) into this identity's mailbox."""
150
+ return self.client.request("POST", self.path + "/mail/import", {"filename": filename, "content_base64": content_base64})
151
+
152
+ def events(self, consumer="default", after=None):
153
+ return self.client.request("GET", self.path + "/events", params={"consumer": consumer, **({"after": after} if after is not None else {})})
154
+
155
+ def ack(self, cursor, consumer="default"):
156
+ return self.client.request("POST", self.path + "/events/ack", {"cursor": cursor, "consumer": consumer})
157
+
158
+ def watch(self, consumer="default", poll_interval=2, ack=True):
159
+ """At-least-once delivery: a batch is acknowledged only after every event in it was yielded and handled by the caller."""
160
+ while True:
161
+ batch = self.events(consumer)
162
+ for event in batch["events"]:
163
+ yield event
164
+ if ack and batch["events"]:
165
+ self.ack(batch["next_cursor"], consumer)
166
+ time.sleep(max(0.1, poll_interval))
167
+
168
+ def resources(self, kind=None):
169
+ return self.client.request("GET", "/v1/resources", params={"kind": kind} if kind else None)
170
+
171
+ def create_resource(self, kind, name, data):
172
+ return self.client.request("POST", "/v1/resources", {"kind": kind, "name": name, "data": data, "identity_ids": [self.id]})
173
+
174
+ def endpoint(self):
175
+ return self.client.request("POST", self.path + "/endpoint")
176
+
177
+ def submit_task(self, target_id, text):
178
+ return self.client.request("POST", self.path + "/tasks", {"target_id": target_id, "input": text})
179
+
180
+ def tasks(self):
181
+ return self.client.request("GET", self.path + "/tasks")
182
+
183
+ def update_task(self, task_id, status, result=None):
184
+ return self.client.request("PATCH", f"/v1/tasks/{uid(task_id)}", {"status": status, "result": result})
185
+
186
+ def drafts(self):
187
+ return self.client.request("GET", self.path + "/drafts")
188
+
189
+ def create_draft(self, **fields):
190
+ return self.client.request("POST", self.path + "/drafts", fields)
191
+
192
+ def update_draft(self, draft_id, version, **fields):
193
+ """Optimistic concurrency: pass the version you last read."""
194
+ return self.client.request("PATCH", self.path + "/drafts/" + uid(draft_id), {"version": version, **fields})
195
+
196
+ def attach_draft(self, draft_id, files):
197
+ return self.client.request("POST", self.path + "/drafts/" + uid(draft_id) + "/attachments", {"attachments": files})
198
+
199
+ def send_draft(self, draft_id, idempotency_key=None):
200
+ return self.client.request("POST", self.path + "/drafts/" + uid(draft_id) + "/send", {}, headers={"Idempotency-Key": idempotency_key or str(uuid.uuid4())})
201
+
202
+ def duplicate_draft(self, draft_id):
203
+ return self.client.request("POST", self.path + "/drafts/" + uid(draft_id) + "/duplicate")
204
+
205
+ def revise_draft(self, draft_id, instructions):
206
+ return self.client.request("POST", self.path + "/drafts/" + uid(draft_id) + "/revise", {"instructions": instructions})
207
+
208
+ def contacts(self, q=None):
209
+ return self.client.request("GET", "/v1/contacts", params={"q": q} if q else None)
210
+
211
+ def contact(self, contact_id):
212
+ return self.client.request("GET", "/v1/contacts/" + uid(contact_id))
213
+
214
+ def create_contact(self, **fields):
215
+ return self.client.request("POST", "/v1/contacts", fields)
216
+
217
+ def memories(self, contact_id):
218
+ return self.client.request("GET", "/v1/contacts/" + uid(contact_id) + "/memories")
219
+
220
+ def remember(self, contact_id, kind, content, citations):
221
+ """Citations are [{"message_id": ..., "quote": ...}] taken from messages this identity can read."""
222
+ return self.client.request("POST", "/v1/contacts/" + uid(contact_id) + "/memories", {"kind": kind, "content": content, "citations": citations})
223
+
224
+ def texts(self, q=None):
225
+ return self.client.request("GET", self.path + "/texts", params={"q": q} if q else None)
226
+
227
+ def calls(self):
228
+ return self.client.request("GET", self.path + "/calls")
229
+
230
+ def transcript(self, call_id):
231
+ return self.client.request("GET", self.path + "/calls/" + call_sid(call_id) + "/transcript")
232
+
233
+ def imessage_evaluate(self, number):
234
+ return self.client.request("GET", self.path + "/imessage/evaluate", params={"number": number})
235
+
236
+ def imessage_send(self, body, recipient=None, group_id=None, recipients=None, attachments=None, send_style=None, idempotency_key=None):
237
+ """Send to one recipient, an existing group_id, or a new group of recipients."""
238
+ data = {"body": body, "recipient": recipient, "group_id": group_id, "recipients": recipients, "attachments": attachments, "send_style": send_style}
239
+ return self.client.request("POST", self.path + "/imessage/messages", {k: v for k, v in data.items() if v is not None},
240
+ headers={"Idempotency-Key": idempotency_key or str(uuid.uuid4())})
241
+
242
+ def imessage_groups(self):
243
+ return self.client.request("GET", self.path + "/imessage/groups")
244
+
245
+ def imessage_react(self, message_id, reaction, remove=False):
246
+ return self.client.request("POST", self.path + "/imessage/messages/" + uid(message_id) + "/reaction", {"reaction": reaction, "remove": remove})
247
+
248
+ def imessage_typing(self, recipient):
249
+ return self.client.request("POST", self.path + "/imessage/typing", {"recipient": recipient})
250
+
251
+ def companion(self):
252
+ return self.client.request("GET", self.path + "/companion")
253
+
254
+ def update_companion(self, **fields):
255
+ return self.client.request("PUT", self.path + "/companion", fields)
256
+
257
+ class Mailbox:
258
+ def __init__(self, agent):
259
+ self.agent = agent
260
+
261
+ @property
262
+ def address(self):
263
+ mailbox = self.agent.identity().get("mailbox")
264
+ return mailbox["email_address"] if mailbox else None
265
+
266
+ def send(self, recipient, body, subject="", idempotency_key=None, in_reply_to=None, transport="simulated", attachments=None):
267
+ return self.agent.send(recipient, body, subject, idempotency_key=idempotency_key, in_reply_to=in_reply_to, transport=transport, attachments=attachments)
268
+
269
+ def wait_for_otp(self, timeout=60, after=None, sender=None):
270
+ from datetime import datetime, timezone
271
+ start = time.monotonic()
272
+ # By default ignore old OTPs already sitting in the mailbox.
273
+ after = after or datetime.now(timezone.utc).isoformat()
274
+ while time.monotonic() - start < timeout:
275
+ for message in self.agent.messages(after=after):
276
+ if message["direction"] != "inbound" or (sender and message["sender"].lower() != sender.lower()):
277
+ continue
278
+ match = re.search(r"(?<!\d)(\d{6})(?!\d)", message["subject"] + " " + message["body"])
279
+ if match:
280
+ return match.group(1)
281
+ time.sleep(1)
282
+ raise TimeoutError("No matching OTP received")