diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..5075ade --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,66 @@ +# .github/workflows/tests.yml + +name: Lint & Unit Tests + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +jobs: + python: + name: Python – Lint & Test + runs-on: ubuntu-latest + defaults: + run: + shell: bash -l {0} + working-directory: python + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + version: "latest" + + - name: Cache uv dependencies + uses: actions/cache@v4 + with: + path: ~/.cache/uv + key: ${{ runner.os }}-uv-python-${{ hashFiles('python/pyproject.toml') }} + + - name: Install dependencies + run: uv sync --extra dev + + - name: Lint + run: uv run ruff check . + + - name: Run unit tests + run: uv run pytest tests/ -v --cov=inkbox --cov-report=term-missing --cov-fail-under=70 + + typescript: + name: TypeScript – Lint & Build + runs-on: ubuntu-latest + defaults: + run: + shell: bash -l {0} + working-directory: typescript + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "18" + + - name: Install dependencies + run: npm install --ignore-scripts + + - name: Type check + run: npx tsc --noEmit diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..df4f98c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Vectorly, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 79f1ed3..547c4f8 100644 --- a/README.md +++ b/README.md @@ -1,93 +1,561 @@ -# Inkbox Mail SDK +# Inkbox SDK -Official SDKs for the [Inkbox Mail API](https://inkbox.ai) — API-first email for AI agents. +Official SDKs for the [Inkbox API](https://www.inkbox.ai/docs) — API-first communication infrastructure for AI agents (identities, email, phone). | Package | Language | Install | |---|---|---| -| [`inkbox-mail`](./python/) | Python ≥ 3.11 | `pip install inkbox-mail` | -| [`@inkbox/mail`](./typescript/) | TypeScript / Node ≥ 18 | `npm install @inkbox/mail` | +| [`inkbox`](./python/) | Python ≥ 3.11 | `pip install inkbox` | +| [`@inkbox/sdk`](./typescript/) | TypeScript / Node ≥ 18 | `npm install @inkbox/sdk` | --- -## Python +## Authentication -```python -import asyncio -from inkbox_mail import InkboxMail +All SDK calls require an API key. You can obtain one from the [Inkbox Console](https://console.inkbox.ai/). + +--- -async def main(): - async with InkboxMail(api_key="sk-...") as client: +## Identities - # Create a mailbox - mailbox = await client.mailboxes.create(display_name="Agent 01") +Agent identities are the central concept — a named identity (e.g. `"sales-agent"`) that owns a mailbox and/or phone number. Use `Inkbox` as the org-level entry point to create and retrieve identities. + +### Python + +```python +from inkbox import Inkbox - # Send an email - await client.messages.send( - mailbox.id, - to=["user@example.com"], - subject="Hello from Inkbox", - body_text="Hi there!", - ) +with Inkbox(api_key="ApiKey_...") as inkbox: + # Create an identity — returns an AgentIdentity object + identity = inkbox.create_identity("sales-agent") - # Iterate over all messages (pagination handled automatically) - async for msg in client.messages.list(mailbox.id): - print(msg.subject, msg.from_address) + # Create and link new channels + mailbox = identity.create_mailbox(display_name="Sales Agent") + phone = identity.provision_phone_number(type="toll_free") - # Reply to a message - detail = await client.messages.get(mailbox.id, msg.id) - await client.messages.send( - mailbox.id, - to=detail.to_addresses, - subject=f"Re: {detail.subject}", - body_text="Got it, thanks!", - in_reply_to_message_id=detail.message_id, - ) + print(mailbox.email_address) + print(phone.number) - # Search - results = await client.mailboxes.search(mailbox.id, q="invoice") + # Link an existing mailbox or phone number instead of creating new ones + identity.assign_mailbox("mailbox-uuid-here") + identity.assign_phone_number("phone-number-uuid-here") - # Webhooks (secret is one-time — save it immediately) - hook = await client.webhooks.create( - mailbox.id, - url="https://yourapp.com/hooks/mail", - event_types=["message.received"], - ) - print(hook.secret) # save this + # Unlink channels without deleting them + identity.unlink_mailbox() + identity.unlink_phone_number() -asyncio.run(main()) + # List, get, update, delete, refresh + identities = inkbox.list_identities() + identity = inkbox.get_identity("sales-agent") + identity.update(status="paused") # or new_handle="new-name" + identity.refresh() # re-fetch from API, updates cached channels + identity.delete() # soft-delete; unlinks channels ``` -## TypeScript +### TypeScript ```ts -import { InkboxMail } from "@inkbox/mail"; +import { Inkbox } from "@inkbox/sdk"; + +const inkbox = new Inkbox({ apiKey: "ApiKey_..." }); + +// Create an identity — returns an AgentIdentity object +const identity = await inkbox.createIdentity("sales-agent"); + +// Create and link new channels +const mailbox = await identity.createMailbox({ displayName: "Sales Agent" }); +const phone = await identity.provisionPhoneNumber({ type: "toll_free" }); + +console.log(mailbox.emailAddress); +console.log(phone.number); + +// Link an existing mailbox or phone number instead of creating new ones +await identity.assignMailbox("mailbox-uuid-here"); +await identity.assignPhoneNumber("phone-number-uuid-here"); + +// Unlink channels without deleting them +await identity.unlinkMailbox(); +await identity.unlinkPhoneNumber(); + +// List, get, update, delete, refresh +const identities = await inkbox.listIdentities(); +const i = await inkbox.getIdentity("sales-agent"); +await i.update({ status: "paused" }); // or newHandle: "new-name" +await i.refresh(); // re-fetch from API, updates cached channels +await i.delete(); // soft-delete; unlinks channels +``` + +--- + +## Mail + +### Python + +```python +from inkbox import Inkbox + +with Inkbox(api_key="ApiKey_...") as inkbox: + identity = inkbox.get_identity("sales-agent") + + # Send an email (plain text and/or HTML) + sent = identity.send_email( + to=["user@example.com"], + subject="Hello from Inkbox", + body_text="Hi there!", + body_html="

Hi there!

", + cc=["manager@example.com"], + bcc=["archive@example.com"], + ) + + # Send a threaded reply + identity.send_email( + to=["user@example.com"], + subject=f"Re: {sent.subject}", + body_text="Following up!", + in_reply_to_message_id=sent.id, + ) -const client = new InkboxMail({ apiKey: "sk-..." }); + # Send with attachments + identity.send_email( + to=["user@example.com"], + subject="See attached", + body_text="Please find the file attached.", + attachments=[{ + "filename": "report.pdf", + "content_type": "application/pdf", + "content_base64": "", + }], + ) -// Create a mailbox -const mailbox = await client.mailboxes.create({ displayName: "Agent 01" }); + # Iterate over all messages (pagination handled automatically) + for msg in identity.iter_emails(): + print(msg.subject, msg.from_address, msg.is_read) -// Send an email -await client.messages.send(mailbox.id, { + # Filter by direction: "inbound" or "outbound" + for msg in identity.iter_emails(direction="inbound"): + print(msg.subject) + + # Iterate only unread messages + for msg in identity.iter_unread_emails(): + print(msg.subject) + + # Mark messages as read + unread_ids = [msg.id for msg in identity.iter_unread_emails()] + identity.mark_emails_read(unread_ids) + + # Get a full thread (all messages, oldest-first) + for msg in identity.iter_emails(): + thread = identity.get_thread(msg.thread_id) + for m in thread.messages: + print(f"[{m.from_address}] {m.subject}") + break +``` + +### TypeScript + +```ts +import { Inkbox } from "@inkbox/sdk"; + +const inkbox = new Inkbox({ apiKey: "ApiKey_..." }); +const identity = await inkbox.getIdentity("sales-agent"); + +// Send an email (plain text and/or HTML) +const sent = await identity.sendEmail({ to: ["user@example.com"], subject: "Hello from Inkbox", bodyText: "Hi there!", + bodyHtml: "

Hi there!

", + cc: ["manager@example.com"], + bcc: ["archive@example.com"], +}); + +// Send a threaded reply +await identity.sendEmail({ + to: ["user@example.com"], + subject: `Re: ${sent.subject}`, + bodyText: "Following up!", + inReplyToMessageId: sent.id, +}); + +// Send with attachments +await identity.sendEmail({ + to: ["user@example.com"], + subject: "See attached", + bodyText: "Please find the file attached.", + attachments: [{ + filename: "report.pdf", + contentType: "application/pdf", + contentBase64: "", + }], }); // Iterate over all messages (pagination handled automatically) -for await (const msg of client.messages.list(mailbox.id)) { +for await (const msg of identity.iterEmails()) { + console.log(msg.subject, msg.fromAddress, msg.isRead); +} + +// Filter by direction: "inbound" or "outbound" +for await (const msg of identity.iterEmails({ direction: "inbound" })) { + console.log(msg.subject); +} + +// Iterate only unread messages +for await (const msg of identity.iterUnreadEmails()) { + console.log(msg.subject); +} + +// Mark messages as read +const unreadIds: string[] = []; +for await (const msg of identity.iterUnreadEmails()) unreadIds.push(msg.id); +await identity.markEmailsRead(unreadIds); + +// Get a full thread (all messages, oldest-first) +for await (const msg of identity.iterEmails()) { + const thread = await identity.getThread(msg.threadId); + for (const m of thread.messages) { + console.log(`[${m.fromAddress}] ${m.subject}`); + } + break; +} +``` + +--- + +## Phone + +### Python + +```python +from inkbox import Inkbox + +with Inkbox(api_key="ApiKey_...") as inkbox: + identity = inkbox.get_identity("sales-agent") + + # Place an outbound call — stream audio over WebSocket + call = identity.place_call( + to_number="+15167251294", + client_websocket_url="wss://your-agent.example.com/ws", + ) + print(call.status) + print(call.rate_limit.calls_remaining) + + # Or receive call events via webhook instead + call = identity.place_call( + to_number="+15167251294", + webhook_url="https://your-agent.example.com/call-events", + ) + + # List calls (paginated) + calls = identity.list_calls(limit=10, offset=0) + for call in calls: + print(call.id, call.direction, call.remote_phone_number, call.status) + + # Fetch transcript segments for a call + segments = identity.list_transcripts(calls[0].id) + for t in segments: + print(f"[{t.party}] {t.text}") # party: "local" or "remote" +``` + +### TypeScript + +```ts +import { Inkbox } from "@inkbox/sdk"; + +const inkbox = new Inkbox({ apiKey: "ApiKey_..." }); +const identity = await inkbox.getIdentity("sales-agent"); + +// Place an outbound call — stream audio over WebSocket +const call = await identity.placeCall({ + toNumber: "+15167251294", + clientWebsocketUrl: "wss://your-agent.example.com/ws", +}); +console.log(call.status); +console.log(call.rateLimit.callsRemaining); + +// Or receive call events via webhook instead +const call2 = await identity.placeCall({ + toNumber: "+15167251294", + webhookUrl: "https://your-agent.example.com/call-events", +}); + +// List calls (paginated) +const calls = await identity.listCalls({ limit: 10, offset: 0 }); +for (const c of calls) { + console.log(c.id, c.direction, c.remotePhoneNumber, c.status); +} + +// Fetch transcript segments for a call +const segments = await identity.listTranscripts(calls[0].id); +for (const t of segments) { + console.log(`[${t.party}] ${t.text}`); // party: "local" or "remote" +} +``` + +--- + +## Org-level mailboxes + +Manage mailboxes directly without going through an identity. Access via `inkbox.mailboxes`. + +### Python + +```python +from inkbox import Inkbox + +with Inkbox(api_key="ApiKey_...") as inkbox: + # List all mailboxes in the organisation + mailboxes = inkbox.mailboxes.list() + + # Get a specific mailbox + mailbox = inkbox.mailboxes.get("abc-xyz@inkboxmail.com") + + # Create a standalone mailbox + mailbox = inkbox.mailboxes.create(display_name="Support Inbox") + print(mailbox.email_address) + + # Update display name or webhook URL + inkbox.mailboxes.update(mailbox.email_address, display_name="New Name") + inkbox.mailboxes.update(mailbox.email_address, webhook_url="https://example.com/hook") + inkbox.mailboxes.update(mailbox.email_address, webhook_url=None) # remove webhook + + # Full-text search across messages in a mailbox + results = inkbox.mailboxes.search(mailbox.email_address, q="invoice", limit=20) + for msg in results: + print(msg.subject, msg.from_address) + + # Delete a mailbox + inkbox.mailboxes.delete(mailbox.email_address) +``` + +### TypeScript + +```ts +import { Inkbox } from "@inkbox/sdk"; + +const inkbox = new Inkbox({ apiKey: "ApiKey_..." }); + +// List all mailboxes in the organisation +const mailboxes = await inkbox.mailboxes.list(); + +// Get a specific mailbox +const mailbox = await inkbox.mailboxes.get("abc-xyz@inkboxmail.com"); + +// Create a standalone mailbox +const mb = await inkbox.mailboxes.create({ displayName: "Support Inbox" }); +console.log(mb.emailAddress); + +// Update display name or webhook URL +await inkbox.mailboxes.update(mb.emailAddress, { displayName: "New Name" }); +await inkbox.mailboxes.update(mb.emailAddress, { webhookUrl: "https://example.com/hook" }); +await inkbox.mailboxes.update(mb.emailAddress, { webhookUrl: null }); // remove webhook + +// Full-text search across messages in a mailbox +const results = await inkbox.mailboxes.search(mb.emailAddress, { q: "invoice", limit: 20 }); +for (const msg of results) { console.log(msg.subject, msg.fromAddress); } -// Search -const results = await client.mailboxes.search(mailbox.id, { q: "invoice" }); +// Delete a mailbox +await inkbox.mailboxes.delete(mb.emailAddress); +``` + +--- + +## Org-level phone numbers + +Manage phone numbers directly without going through an identity. Access via `inkbox.phone_numbers` (Python) / `inkbox.phoneNumbers` (TypeScript). + +### Python + +```python +from inkbox import Inkbox + +with Inkbox(api_key="ApiKey_...") as inkbox: + # List all phone numbers in the organisation + numbers = inkbox.phone_numbers.list() + + # Get a specific phone number by ID + number = inkbox.phone_numbers.get("phone-number-uuid") + + # Provision a new number + number = inkbox.phone_numbers.provision(type="toll_free") + local = inkbox.phone_numbers.provision(type="local", state="NY") + + # Update incoming call behaviour + inkbox.phone_numbers.update( + number.id, + incoming_call_action="webhook", + incoming_call_webhook_url="https://example.com/calls", + ) + inkbox.phone_numbers.update( + number.id, + incoming_call_action="auto_accept", + client_websocket_url="wss://example.com/ws", + ) + + # Full-text search across transcripts + hits = inkbox.phone_numbers.search_transcripts(number.id, q="refund", party="remote") + for t in hits: + print(f"[{t.party}] {t.text}") + + # Release a number + inkbox.phone_numbers.release(number=number.number) +``` + +### TypeScript + +```ts +import { Inkbox } from "@inkbox/sdk"; + +const inkbox = new Inkbox({ apiKey: "ApiKey_..." }); + +// List all phone numbers in the organisation +const numbers = await inkbox.phoneNumbers.list(); + +// Get a specific phone number by ID +const number = await inkbox.phoneNumbers.get("phone-number-uuid"); + +// Provision a new number +const num = await inkbox.phoneNumbers.provision({ type: "toll_free" }); +const local = await inkbox.phoneNumbers.provision({ type: "local", state: "NY" }); + +// Update incoming call behaviour +await inkbox.phoneNumbers.update(num.id, { + incomingCallAction: "webhook", + incomingCallWebhookUrl: "https://example.com/calls", +}); +await inkbox.phoneNumbers.update(num.id, { + incomingCallAction: "auto_accept", + clientWebsocketUrl: "wss://example.com/ws", +}); + +// Full-text search across transcripts +const hits = await inkbox.phoneNumbers.searchTranscripts(num.id, { q: "refund", party: "remote" }); +for (const t of hits) { + console.log(`[${t.party}] ${t.text}`); +} + +// Release a number +await inkbox.phoneNumbers.release({ number: num.number }); +``` + +--- + +## Webhooks + +Webhooks are configured on the mailbox or phone number resource — no separate registration step. + +### Mailbox webhooks + +Set a URL on a mailbox to receive `message.received` and `message.sent` events. + +```python +# Python +inkbox.mailboxes.update("abc@inkboxmail.com", webhook_url="https://example.com/hook") +# Remove: +inkbox.mailboxes.update("abc@inkboxmail.com", webhook_url=None) +``` + +```ts +// TypeScript +await inkbox.mailboxes.update("abc@inkboxmail.com", { webhookUrl: "https://example.com/hook" }); +// Remove: +await inkbox.mailboxes.update("abc@inkboxmail.com", { webhookUrl: null }); +``` + +### Phone webhooks + +Set an incoming call webhook URL and action on a phone number. + +```python +# Python — route incoming calls to a webhook +inkbox.phone_numbers.update( + number.id, + incoming_call_action="webhook", + incoming_call_webhook_url="https://example.com/calls", +) +``` + +```ts +// TypeScript — route incoming calls to a webhook +await inkbox.phoneNumbers.update(number.id, { + incomingCallAction: "webhook", + incomingCallWebhookUrl: "https://example.com/calls", +}); +``` + +You can also supply a per-call webhook URL when placing a call: + +```python +# Python +identity.place_call(to_number="+15005550006", webhook_url="https://example.com/call-events") +``` + +```ts +// TypeScript +await identity.placeCall({ toNumber: "+15005550006", webhookUrl: "https://example.com/call-events" }); +``` + +--- + +## Signing Keys + +Org-level webhook signing keys are managed through the `Inkbox` client. + +### Python + +```python +from inkbox import Inkbox + +with Inkbox(api_key="ApiKey_...") as inkbox: + # Create or rotate the org-level signing key (plaintext returned once) + key = inkbox.create_signing_key() + print(key.signing_key) # save this +``` + +### TypeScript + +```ts +import { Inkbox } from "@inkbox/sdk"; + +const inkbox = new Inkbox({ apiKey: "ApiKey_..." }); + +// Create or rotate the org-level signing key (plaintext returned once) +const key = await inkbox.createSigningKey(); +console.log(key.signingKey); // save this +``` + +--- + +## Verifying Webhook Signatures + +Use `verify_webhook` / `verifyWebhook` to confirm that an incoming request was sent by Inkbox. The function checks the HMAC-SHA256 signature over `{request_id}.{timestamp}.{body}`. + +### Python + +```python +from inkbox import verify_webhook + +is_valid = verify_webhook( + payload=raw_body, # bytes + signature=request.headers["X-Inkbox-Signature"], + request_id=request.headers["X-Inkbox-Request-ID"], + timestamp=request.headers["X-Inkbox-Timestamp"], + secret="whsec_...", # from create_signing_key() +) +``` + +### TypeScript + +```ts +import { verifyWebhook } from "@inkbox/sdk"; -// Webhooks (secret is one-time — save it immediately) -const hook = await client.webhooks.create(mailbox.id, { - url: "https://yourapp.com/hooks/mail", - eventTypes: ["message.received"], +const valid = verifyWebhook({ + payload: req.body, // Buffer or string + signature: req.headers["x-inkbox-signature"] as string, + requestId: req.headers["x-inkbox-request-id"] as string, + timestamp: req.headers["x-inkbox-timestamp"] as string, + secret: "whsec_...", // from createSigningKey() }); -console.log(hook.secret); // save this ``` --- diff --git a/python/.coverage b/python/.coverage new file mode 100644 index 0000000..6d5ccec Binary files /dev/null and b/python/.coverage differ diff --git a/python/README.md b/python/README.md index e5cd836..7c79ceb 100644 --- a/python/README.md +++ b/python/README.md @@ -1,6 +1,6 @@ # inkbox -Python SDK for the [Inkbox Mail API](https://inkbox.ai) — API-first email for AI agents. +Python SDK for the [Inkbox API](https://www.inkbox.ai/docs) — API-first communication infrastructure for AI agents (email, phone, identities). ## Install @@ -8,53 +8,366 @@ Python SDK for the [Inkbox Mail API](https://inkbox.ai) — API-first email for pip install inkbox ``` -## Usage +Requires Python ≥ 3.11. + +## Authentication + +You'll need an API key to use this SDK. Get one at [console.inkbox.ai](https://console.inkbox.ai/). + +## Quick start + +```python +import os +from inkbox import Inkbox + +with Inkbox(api_key=os.environ["INKBOX_API_KEY"]) as inkbox: + # Create an agent identity + identity = inkbox.create_identity("support-bot") + + # Create and link new channels + identity.create_mailbox(display_name="Support Bot") + identity.provision_phone_number(type="toll_free") + + # Send email directly from the identity + identity.send_email( + to=["customer@example.com"], + subject="Your order has shipped", + body_text="Tracking number: 1Z999AA10123456784", + ) + + # Place an outbound call + identity.place_call( + to_number="+18005559999", + client_websocket_url="wss://my-app.com/voice", + ) + + # Read inbox + for message in identity.iter_emails(): + print(message.subject) + + # List calls + calls = identity.list_calls() +``` + +## Authentication + +| Argument | Type | Default | Description | +|---|---|---|---| +| `api_key` | `str` | required | Your `ApiKey_...` token | +| `base_url` | `str` | API default | Override for self-hosting or testing | +| `timeout` | `float` | `30.0` | Request timeout in seconds | + +Use `with Inkbox(...) as inkbox:` (recommended) or call `inkbox.close()` manually to clean up HTTP connections. + +--- + +## Identities + +`inkbox.create_identity()` and `inkbox.get_identity()` return an `AgentIdentity` object that holds the identity's channels and exposes convenience methods scoped to those channels. ```python -from inkbox.mail import InkboxMail +# Create and fully provision an identity +identity = inkbox.create_identity("sales-bot") +mailbox = identity.create_mailbox(display_name="Sales Bot") # creates + links +phone = identity.provision_phone_number(type="toll_free") # provisions + links + +print(mailbox.email_address) +print(phone.number) + +# Link an existing mailbox or phone number instead of creating new ones +identity.assign_mailbox("mailbox-uuid-here") +identity.assign_phone_number("phone-number-uuid-here") + +# Get an existing identity +identity = inkbox.get_identity("sales-bot") +identity.refresh() # re-fetch channels from API + +# List all identities for your org +all_identities = inkbox.list_identities() + +# Update status or handle +identity.update(status="paused") +identity.update(new_handle="sales-bot-v2") + +# Unlink channels (without deleting them) +identity.unlink_mailbox() +identity.unlink_phone_number() + +# Delete +identity.delete() +``` -client = InkboxMail(api_key="sk-...") +--- -# Create a mailbox -mailbox = client.mailboxes.create(display_name="Agent 01") +## Mail -# Send an email -client.messages.send( - mailbox.id, +```python +# Send an email (plain text and/or HTML) +sent = identity.send_email( to=["user@example.com"], subject="Hello from Inkbox", body_text="Hi there!", + body_html="

Hi there!

", + cc=["manager@example.com"], + bcc=["archive@example.com"], +) + +# Send a threaded reply +identity.send_email( + to=["user@example.com"], + subject=f"Re: {sent.subject}", + body_text="Following up!", + in_reply_to_message_id=sent.id, +) + +# Send with attachments +identity.send_email( + to=["user@example.com"], + subject="See attached", + body_text="Please find the file attached.", + attachments=[{ + "filename": "report.pdf", + "content_type": "application/pdf", + "content_base64": "", + }], +) + +# Iterate inbox (paginated automatically) +for msg in identity.iter_emails(): + print(msg.subject, msg.from_address, msg.is_read) + +# Filter by direction: "inbound" or "outbound" +for msg in identity.iter_emails(direction="inbound"): + print(msg.subject) + +# Iterate only unread emails +for msg in identity.iter_unread_emails(): + print(msg.subject) + +# Mark messages as read +identity.mark_emails_read([msg.id for msg in identity.iter_unread_emails()]) + +# Get all emails in a thread (thread_id comes from msg.thread_id) +thread = identity.get_thread(msg.thread_id) +for m in thread.messages: + print(m.subject, m.from_address) +``` + +--- + +## Phone + +```python +# Place an outbound call — stream audio over WebSocket +call = identity.place_call( + to_number="+15167251294", + client_websocket_url="wss://your-agent.example.com/ws", +) +print(call.status, call.rate_limit.calls_remaining) + +# Or receive call events via webhook instead +call = identity.place_call( + to_number="+15167251294", + webhook_url="https://your-agent.example.com/call-events", ) -# Iterate over all messages (pagination handled automatically) -for msg in client.messages.list(mailbox.id): +# List calls (paginated) +calls = identity.list_calls(limit=10, offset=0) +for call in calls: + print(call.id, call.direction, call.remote_phone_number, call.status) + +# Fetch transcript segments for a call +segments = identity.list_transcripts(calls[0].id) +for t in segments: + print(f"[{t.party}] {t.text}") # party: "local" or "remote" + +# Read transcripts across all recent calls +for call in identity.list_calls(limit=10): + segments = identity.list_transcripts(call.id) + if not segments: + continue + print(f"\n--- Call {call.id} ({call.direction}) ---") + for t in segments: + print(f" [{t.party:6}] {t.text}") + +# Filter to only the remote party's speech +for t in identity.list_transcripts(calls[0].id): + if t.party == "remote": + print(t.text) + +# Search transcripts across a phone number (org-level) +hits = inkbox.phone_numbers.search_transcripts(phone.id, q="refund", party="remote") +for t in hits: + print(f"[{t.party}] {t.text}") +``` + +--- + +## Org-level Mailboxes + +Manage mailboxes directly without going through an identity. Access via `inkbox.mailboxes`. + +```python +# List all mailboxes in the organisation +mailboxes = inkbox.mailboxes.list() + +# Get a specific mailbox +mailbox = inkbox.mailboxes.get("abc-xyz@inkboxmail.com") + +# Create a standalone mailbox +mailbox = inkbox.mailboxes.create(display_name="Support Inbox") +print(mailbox.email_address) + +# Update display name or webhook URL +inkbox.mailboxes.update(mailbox.email_address, display_name="New Name") +inkbox.mailboxes.update(mailbox.email_address, webhook_url="https://example.com/hook") +inkbox.mailboxes.update(mailbox.email_address, webhook_url=None) # remove webhook + +# Full-text search across messages in a mailbox +results = inkbox.mailboxes.search(mailbox.email_address, q="invoice", limit=20) +for msg in results: print(msg.subject, msg.from_address) -# Reply to a message -detail = client.messages.get(mailbox.id, msg.id) -client.messages.send( - mailbox.id, - to=detail.to_addresses, - subject=f"Re: {detail.subject}", - body_text="Got it, thanks!", - in_reply_to_message_id=detail.message_id, +# Delete a mailbox +inkbox.mailboxes.delete(mailbox.email_address) +``` + +--- + +## Org-level Phone Numbers + +Manage phone numbers directly without going through an identity. Access via `inkbox.phone_numbers`. + +```python +# List all phone numbers in the organisation +numbers = inkbox.phone_numbers.list() + +# Get a specific phone number by ID +number = inkbox.phone_numbers.get("phone-number-uuid") + +# Provision a new number +number = inkbox.phone_numbers.provision(type="toll_free") +local = inkbox.phone_numbers.provision(type="local", state="NY") + +# Update incoming call behaviour +inkbox.phone_numbers.update( + number.id, + incoming_call_action="webhook", + incoming_call_webhook_url="https://example.com/calls", ) +inkbox.phone_numbers.update( + number.id, + incoming_call_action="auto_accept", + client_websocket_url="wss://example.com/ws", +) + +# Full-text search across transcripts +hits = inkbox.phone_numbers.search_transcripts(number.id, q="refund", party="remote") +for t in hits: + print(f"[{t.party}] {t.text}") -# Search -results = client.mailboxes.search(mailbox.id, q="invoice") +# Release a number +inkbox.phone_numbers.release(number=number.number) +``` -# Webhooks (secret is one-time — save it immediately) -hook = client.webhooks.create( - mailbox.id, - url="https://yourapp.com/hooks/mail", - event_types=["message.received"], +--- + +## Webhooks + +Webhooks are configured on the mailbox or phone number resource — no separate registration step. + +### Mailbox webhooks + +Set a URL on a mailbox to receive `message.received` and `message.sent` events. + +```python +# Set webhook +inkbox.mailboxes.update("abc@inkboxmail.com", webhook_url="https://example.com/hook") + +# Remove webhook +inkbox.mailboxes.update("abc@inkboxmail.com", webhook_url=None) +``` + +### Phone webhooks + +Set an incoming call webhook URL and action on a phone number. + +```python +# Route incoming calls to a webhook +inkbox.phone_numbers.update( + number.id, + incoming_call_action="webhook", + incoming_call_webhook_url="https://example.com/calls", ) -print(hook.secret) # save this ``` -## Requirements +You can also supply a per-call webhook URL when placing a call: + +```python +identity.place_call(to_number="+15005550006", webhook_url="https://example.com/call-events") +``` + +--- + +## Signing Keys + +```python +# Create or rotate the org-level webhook signing key (plaintext returned once) +key = inkbox.create_signing_key() +print(key.signing_key) # save this immediately +``` + +--- + +## Verifying Webhook Signatures + +Use `verify_webhook` to confirm that an incoming request was sent by Inkbox. + +```python +from inkbox import verify_webhook + +# FastAPI +@app.post("/hooks/mail") +async def mail_hook(request: Request): + raw_body = await request.body() + if not verify_webhook( + payload=raw_body, + headers=request.headers, + secret="whsec_...", + ): + raise HTTPException(status_code=403) + ... + +# Flask +@app.post("/hooks/mail") +def mail_hook(): + raw_body = request.get_data() + if not verify_webhook( + payload=raw_body, + headers=request.headers, + secret="whsec_...", + ): + abort(403) + ... +``` + +--- + +## Examples + +Runnable example scripts are available in the [examples/python](https://github.com/vectorlyapp/inkbox/tree/main/inkbox/examples/python) directory: -- Python ≥ 3.11 +| Script | What it demonstrates | +|---|---| +| `register_agent_identity.py` | Create an identity, assign mailbox + phone number | +| `agent_send_email.py` | Send an email and a threaded reply | +| `read_agent_messages.py` | List messages and threads | +| `create_agent_mailbox.py` | Create, update, search, and delete a mailbox | +| `create_agent_phone_number.py` | Provision, update, and release a number | +| `list_agent_phone_numbers.py` | List all phone numbers in the org | +| `read_agent_calls.py` | List calls and print transcripts | +| `receive_agent_email_webhook.py` | Register and delete a mailbox webhook | +| `receive_agent_call_webhook.py` | Register, update, and delete a phone webhook | ## License diff --git a/python/inkbox/__init__.py b/python/inkbox/__init__.py index b04400a..0d7b542 100644 --- a/python/inkbox/__init__.py +++ b/python/inkbox/__init__.py @@ -1 +1,65 @@ -# inkbox namespace package +""" +inkbox — Python SDK for the Inkbox APIs. +""" + +from inkbox.client import Inkbox +from inkbox.agent_identity import AgentIdentity + +# Exceptions (canonical source: mail; identical in all submodules) +from inkbox.mail.exceptions import InkboxAPIError, InkboxError + +# Mail types +from inkbox.mail.types import ( + Mailbox, + Message, + MessageDetail, + Thread, + ThreadDetail, +) + +# Phone types +from inkbox.phone.types import ( + PhoneCall, + PhoneCallWithRateLimit, + PhoneNumber, + PhoneTranscript, + RateLimitInfo, +) + +# Identity types +from inkbox.identities.types import ( + AgentIdentitySummary, + IdentityMailbox, + IdentityPhoneNumber, +) + +# Signing key + webhook verification +from inkbox.signing_keys import SigningKey, verify_webhook + +__all__ = [ + # Entry points + "Inkbox", + "AgentIdentity", + # Exceptions + "InkboxError", + "InkboxAPIError", + # Mail types + "Mailbox", + "Message", + "MessageDetail", + "Thread", + "ThreadDetail", + # Phone types + "PhoneCall", + "PhoneCallWithRateLimit", + "PhoneNumber", + "PhoneTranscript", + "RateLimitInfo", + # Identity types + "AgentIdentitySummary", + "IdentityMailbox", + "IdentityPhoneNumber", + # Signing key + webhook verification + "SigningKey", + "verify_webhook", +] diff --git a/python/inkbox/agent_identity.py b/python/inkbox/agent_identity.py new file mode 100644 index 0000000..52fcf93 --- /dev/null +++ b/python/inkbox/agent_identity.py @@ -0,0 +1,388 @@ +""" +inkbox/agent_identity.py + +AgentIdentity — a domain object representing one agent identity. +Returned by inkbox.create_identity() and inkbox.get_identity(). + +Convenience methods (send_email, place_call, etc.) are scoped to this +agent's assigned channels so callers never need to pass an email address +or phone number ID explicitly. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Iterator + +from inkbox.identities.types import _AgentIdentityData, IdentityMailbox, IdentityPhoneNumber +from inkbox.mail.exceptions import InkboxError +from inkbox.mail.types import Message, ThreadDetail +from inkbox.phone.types import PhoneCall, PhoneCallWithRateLimit, PhoneTranscript + +if TYPE_CHECKING: + from inkbox.client import Inkbox + + +class AgentIdentity: + """An agent identity with convenience methods for its assigned channels. + + Obtain an instance via:: + + identity = inkbox.create_identity("support-bot") + # or + identity = inkbox.get_identity("support-bot") + + After assigning channels you can communicate directly:: + + identity.create_mailbox(display_name="Support Bot") + identity.provision_phone_number(type="toll_free") + + identity.send_email(to=["user@example.com"], subject="Hi", body_text="Hello") + identity.place_call(to_number="+15555550100", client_websocket_url="wss://my-app.com/ws") + + for msg in identity.iter_emails(): + print(msg.subject) + """ + + def __init__(self, data: _AgentIdentityData, inkbox: Inkbox) -> None: + self._data = data + self._inkbox = inkbox + self._mailbox: IdentityMailbox | None = data.mailbox + self._phone_number: IdentityPhoneNumber | None = data.phone_number + + # ------------------------------------------------------------------ + # Identity properties + # ------------------------------------------------------------------ + + @property + def agent_handle(self) -> str: + return self._data.agent_handle + + @property + def id(self): + return self._data.id + + @property + def status(self) -> str: + return self._data.status + + @property + def mailbox(self) -> IdentityMailbox | None: + return self._mailbox + + @property + def phone_number(self) -> IdentityPhoneNumber | None: + return self._phone_number + + # ------------------------------------------------------------------ + # Channel management + # ------------------------------------------------------------------ + + def create_mailbox(self, *, display_name: str | None = None) -> IdentityMailbox: + """Create a new mailbox and link it to this identity. + + Args: + display_name: Optional human-readable sender name. + + Returns: + The newly created and linked mailbox. + """ + mailbox = self._inkbox._mailboxes.create(display_name=display_name) + data = self._inkbox._ids_resource.assign_mailbox( + self.agent_handle, mailbox_id=mailbox.id + ) + self._mailbox = data.mailbox + self._data = data + return self._mailbox # type: ignore[return-value] + + def assign_mailbox(self, mailbox_id: str) -> IdentityMailbox: + """Link an existing mailbox to this identity. + + Args: + mailbox_id: UUID of the mailbox to link. Obtain via + ``inkbox.mailboxes.list()`` or ``inkbox.mailboxes.get()``. + + Returns: + The linked mailbox. + """ + data = self._inkbox._ids_resource.assign_mailbox( + self.agent_handle, mailbox_id=mailbox_id + ) + self._mailbox = data.mailbox + self._data = data + return self._mailbox # type: ignore[return-value] + + def unlink_mailbox(self) -> None: + """Unlink this identity's mailbox (does not delete the mailbox).""" + self._require_mailbox() + self._inkbox._ids_resource.unlink_mailbox(self.agent_handle) + self._mailbox = None + + def provision_phone_number( + self, *, type: str = "toll_free", state: str | None = None + ) -> IdentityPhoneNumber: + """Provision a new phone number and link it to this identity. + + Args: + type: ``"toll_free"`` (default) or ``"local"``. + state: US state abbreviation (e.g. ``"NY"``), valid for local numbers only. + + Returns: + The newly provisioned and linked phone number. + """ + self._inkbox._numbers.provision(agent_handle=self.agent_handle, type=type, state=state) + data = self._inkbox._ids_resource.get(self.agent_handle) + self._phone_number = data.phone_number + self._data = data + return self._phone_number # type: ignore[return-value] + + def assign_phone_number(self, phone_number_id: str) -> IdentityPhoneNumber: + """Link an existing phone number to this identity. + + Args: + phone_number_id: UUID of the phone number to link. Obtain via + ``inkbox.phone_numbers.list()`` or ``inkbox.phone_numbers.get()``. + + Returns: + The linked phone number. + """ + data = self._inkbox._ids_resource.assign_phone_number( + self.agent_handle, phone_number_id=phone_number_id + ) + self._phone_number = data.phone_number + self._data = data + return self._phone_number # type: ignore[return-value] + + def unlink_phone_number(self) -> None: + """Unlink this identity's phone number (does not release the number).""" + self._require_phone() + self._inkbox._ids_resource.unlink_phone_number(self.agent_handle) + self._phone_number = None + + # ------------------------------------------------------------------ + # Mail helpers + # ------------------------------------------------------------------ + + def send_email( + self, + *, + to: list[str], + subject: str, + body_text: str | None = None, + body_html: str | None = None, + cc: list[str] | None = None, + bcc: list[str] | None = None, + in_reply_to_message_id: str | None = None, + attachments: list[dict] | None = None, + ) -> Message: + """Send an email from this identity's mailbox. + + Args: + to: Primary recipient addresses (at least one required). + subject: Email subject line. + body_text: Plain-text body. + body_html: HTML body. + cc: Carbon-copy recipients. + bcc: Blind carbon-copy recipients. + in_reply_to_message_id: RFC 5322 Message-ID to thread a reply. + attachments: List of file attachment dicts with ``filename``, + ``content_type``, and ``content_base64`` keys. + """ + self._require_mailbox() + return self._inkbox._messages.send( + self._mailbox.email_address, # type: ignore[union-attr] + to=to, + subject=subject, + body_text=body_text, + body_html=body_html, + cc=cc, + bcc=bcc, + in_reply_to_message_id=in_reply_to_message_id, + attachments=attachments, + ) + + def iter_emails( + self, + *, + page_size: int = 50, + direction: str | None = None, + ) -> Iterator[Message]: + """Iterate over emails in this identity's inbox, newest first. + + Pagination is handled automatically. + + Args: + page_size: Messages fetched per API call (1–100). + direction: Filter by ``"inbound"`` or ``"outbound"``. + """ + self._require_mailbox() + return self._inkbox._messages.list( + self._mailbox.email_address, # type: ignore[union-attr] + page_size=page_size, + direction=direction, + ) + + def iter_unread_emails( + self, + *, + page_size: int = 50, + direction: str | None = None, + ) -> Iterator[Message]: + """Iterate over unread emails in this identity's inbox, newest first. + + Fetches all messages and filters client-side. Pagination is handled + automatically. + + Args: + page_size: Messages fetched per API call (1–100). + direction: Filter by ``"inbound"`` or ``"outbound"``. + """ + return (msg for msg in self.iter_emails(page_size=page_size, direction=direction) if not msg.is_read) + + def mark_emails_read(self, message_ids: list[str]) -> None: + """Mark a list of messages as read. + + Args: + message_ids: IDs of the messages to mark as read. + """ + self._require_mailbox() + for mid in message_ids: + self._inkbox._messages.mark_read( + self._mailbox.email_address, # type: ignore[union-attr] + mid, + ) + + def get_thread(self, thread_id: str) -> ThreadDetail: + """Get a thread with all its messages inlined (oldest-first). + + Args: + thread_id: UUID of the thread to fetch. Obtain via ``msg.thread_id`` + on any :class:`~inkbox.mail.types.Message`. + """ + self._require_mailbox() + return self._inkbox._threads.get( + self._mailbox.email_address, # type: ignore[union-attr] + thread_id, + ) + + # ------------------------------------------------------------------ + # Phone helpers + # ------------------------------------------------------------------ + + def place_call( + self, + *, + to_number: str, + client_websocket_url: str | None = None, + webhook_url: str | None = None, + ) -> PhoneCallWithRateLimit: + """Place an outbound call from this identity's phone number. + + Args: + to_number: E.164 destination number. + client_websocket_url: WebSocket URL (wss://) for audio bridging. + webhook_url: Custom webhook URL for call lifecycle events. + """ + self._require_phone() + return self._inkbox._calls.place( + from_number=self._phone_number.number, # type: ignore[union-attr] + to_number=to_number, + client_websocket_url=client_websocket_url, + webhook_url=webhook_url, + ) + + def list_calls(self, *, limit: int = 50, offset: int = 0) -> list[PhoneCall]: + """List calls made to/from this identity's phone number. + + Args: + limit: Maximum number of results (default 50). + offset: Pagination offset (default 0). + """ + self._require_phone() + return self._inkbox._calls.list( + self._phone_number.id, # type: ignore[union-attr] + limit=limit, + offset=offset, + ) + + def list_transcripts(self, call_id: str) -> list[PhoneTranscript]: + """List transcript segments for a specific call. + + Args: + call_id: ID of the call to fetch transcripts for. + """ + self._require_phone() + return self._inkbox._transcripts.list( + self._phone_number.id, # type: ignore[union-attr] + call_id, + ) + + # ------------------------------------------------------------------ + # Identity management + # ------------------------------------------------------------------ + + def update( + self, + *, + new_handle: str | None = None, + status: str | None = None, + ) -> None: + """Update this identity's handle or status. + + Args: + new_handle: New agent handle. + status: New lifecycle status: ``"active"`` or ``"paused"``. + """ + result = self._inkbox._ids_resource.update( + self.agent_handle, new_handle=new_handle, status=status + ) + self._data = _AgentIdentityData( + id=result.id, + organization_id=result.organization_id, + agent_handle=result.agent_handle, + status=result.status, + created_at=result.created_at, + updated_at=result.updated_at, + mailbox=self._mailbox, + phone_number=self._phone_number, + ) + + def refresh(self) -> AgentIdentity: + """Re-fetch this identity from the API and update cached channels. + + Returns: + ``self`` for chaining. + """ + data = self._inkbox._ids_resource.get(self.agent_handle) + self._data = data + self._mailbox = data.mailbox + self._phone_number = data.phone_number + return self + + def delete(self) -> None: + """Soft-delete this identity (unlinks channels without deleting them).""" + self._inkbox._ids_resource.delete(self.agent_handle) + + # ------------------------------------------------------------------ + # Internal guards + # ------------------------------------------------------------------ + + def _require_mailbox(self) -> None: + if not self._mailbox: + raise InkboxError( + f"Identity '{self.agent_handle}' has no mailbox assigned. " + "Call identity.create_mailbox() or identity.assign_mailbox() first." + ) + + def _require_phone(self) -> None: + if not self._phone_number: + raise InkboxError( + f"Identity '{self.agent_handle}' has no phone number assigned. " + "Call identity.provision_phone_number() or identity.assign_phone_number() first." + ) + + def __repr__(self) -> str: + return ( + f"AgentIdentity(agent_handle={self.agent_handle!r}, " + f"mailbox={self._mailbox.email_address if self._mailbox else None!r}, " + f"phone={self._phone_number.number if self._phone_number else None!r})" + ) diff --git a/python/inkbox/client.py b/python/inkbox/client.py new file mode 100644 index 0000000..1da0655 --- /dev/null +++ b/python/inkbox/client.py @@ -0,0 +1,153 @@ +""" +inkbox/client.py + +Inkbox — org-level entry point for all Inkbox APIs. +""" + +from __future__ import annotations + +from inkbox.mail._http import HttpTransport as MailHttpTransport +from inkbox.mail.resources.mailboxes import MailboxesResource +from inkbox.mail.resources.messages import MessagesResource +from inkbox.mail.resources.threads import ThreadsResource +from inkbox.phone._http import HttpTransport as PhoneHttpTransport +from inkbox.phone.resources.calls import CallsResource +from inkbox.phone.resources.numbers import PhoneNumbersResource +from inkbox.phone.resources.transcripts import TranscriptsResource +from inkbox.identities._http import HttpTransport as IdsHttpTransport +from inkbox.identities.resources.identities import IdentitiesResource +from inkbox.agent_identity import AgentIdentity +from inkbox.identities.types import AgentIdentitySummary +from inkbox.signing_keys import SigningKey, SigningKeysResource + +_DEFAULT_BASE_URL = "https://api.inkbox.ai" + + +class Inkbox: + """Org-level entry point for all Inkbox APIs. + + Args: + api_key: Your Inkbox API key (``X-Service-Token``). + base_url: Override the API base URL (useful for self-hosting or testing). + timeout: Request timeout in seconds (default 30). + + Example:: + + from inkbox import Inkbox + + with Inkbox(api_key="ApiKey_...") as inkbox: + identity = inkbox.create_identity("support-bot") + identity.create_mailbox(display_name="Support Bot") + identity.send_email( + to=["customer@example.com"], + subject="Hello!", + body_text="Hi there", + ) + """ + + def __init__( + self, + api_key: str, + *, + base_url: str = _DEFAULT_BASE_URL, + timeout: float = 30.0, + ) -> None: + _api_root = f"{base_url.rstrip('/')}/api/v1" + + self._mail_http = MailHttpTransport( + api_key=api_key, base_url=f"{_api_root}/mail", timeout=timeout + ) + self._phone_http = PhoneHttpTransport( + api_key=api_key, base_url=f"{_api_root}/phone", timeout=timeout + ) + self._ids_http = IdsHttpTransport( + api_key=api_key, base_url=f"{_api_root}/identities", timeout=timeout + ) + self._api_http = MailHttpTransport( + api_key=api_key, base_url=_api_root, timeout=timeout + ) + + self._mailboxes = MailboxesResource(self._mail_http) + self._messages = MessagesResource(self._mail_http) + self._threads = ThreadsResource(self._mail_http) + + self._calls = CallsResource(self._phone_http) + self._numbers = PhoneNumbersResource(self._phone_http) + self._transcripts = TranscriptsResource(self._phone_http) + + self._signing_keys = SigningKeysResource(self._api_http) + self._ids_resource = IdentitiesResource(self._ids_http) + + # ------------------------------------------------------------------ + # Public resource accessors + # ------------------------------------------------------------------ + + @property + def mailboxes(self) -> MailboxesResource: + """Access org-level mailbox operations (list, get, create, update, delete).""" + return self._mailboxes + + @property + def phone_numbers(self) -> PhoneNumbersResource: + """Access org-level phone number operations (list, get, provision, release).""" + return self._numbers + + # ------------------------------------------------------------------ + # Org-level operations + # ------------------------------------------------------------------ + + def create_identity(self, agent_handle: str) -> AgentIdentity: + """Create a new agent identity. + + Args: + agent_handle: Unique handle for this identity (e.g. ``"sales-bot"``). + + Returns: + The created :class:`AgentIdentity`. + """ + from inkbox.agent_identity import AgentIdentity + + self._ids_resource.create(agent_handle=agent_handle) + data = self._ids_resource.get(agent_handle) + return AgentIdentity(data, self) + + def get_identity(self, agent_handle: str) -> AgentIdentity: + """Get an agent identity by handle. + + Args: + agent_handle: Handle of the identity to fetch. + + Returns: + The :class:`AgentIdentity`. + """ + from inkbox.agent_identity import AgentIdentity + + return AgentIdentity(self._ids_resource.get(agent_handle), self) + + def list_identities(self) -> list[AgentIdentitySummary]: + """List all agent identities for your organisation.""" + return self._ids_resource.list() + + def create_signing_key(self) -> SigningKey: + """Create or rotate the org-level webhook signing key. + + The plaintext key is returned once — save it immediately. + """ + return self._signing_keys.create_or_rotate() + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def close(self) -> None: + """Close all underlying HTTP connection pools.""" + self._mail_http.close() + self._phone_http.close() + self._ids_http.close() + self._api_http.close() + + def __enter__(self) -> Inkbox: + return self + + def __exit__(self, *_: object) -> None: + self.close() diff --git a/python/inkbox/identities/__init__.py b/python/inkbox/identities/__init__.py new file mode 100644 index 0000000..f2000e8 --- /dev/null +++ b/python/inkbox/identities/__init__.py @@ -0,0 +1,17 @@ +""" +inkbox.identities — identity types. +""" + +from inkbox.identities.types import ( + AgentIdentitySummary, + _AgentIdentityData, + IdentityMailbox, + IdentityPhoneNumber, +) + +__all__ = [ + "AgentIdentitySummary", + "_AgentIdentityData", + "IdentityMailbox", + "IdentityPhoneNumber", +] diff --git a/python/inkbox/identities/_http.py b/python/inkbox/identities/_http.py new file mode 100644 index 0000000..5629f63 --- /dev/null +++ b/python/inkbox/identities/_http.py @@ -0,0 +1,68 @@ +""" +inkbox/identities/_http.py + +Sync HTTP transport (internal). +""" + +from __future__ import annotations + +from typing import Any + +import httpx + +from inkbox.identities.exceptions import InkboxAPIError + +_DEFAULT_TIMEOUT = 30.0 + + +class HttpTransport: + def __init__(self, api_key: str, base_url: str, timeout: float = _DEFAULT_TIMEOUT) -> None: + self._client = httpx.Client( + base_url=base_url, + headers={ + "X-Service-Token": api_key, + "Accept": "application/json", + }, + timeout=timeout, + ) + + def get(self, path: str, *, params: dict[str, Any] | None = None) -> Any: + cleaned = {k: v for k, v in (params or {}).items() if v is not None} + resp = self._client.get(path, params=cleaned) + _raise_for_status(resp) + return resp.json() + + def post(self, path: str, *, json: dict[str, Any] | None = None) -> Any: + resp = self._client.post(path, json=json) + _raise_for_status(resp) + if resp.status_code == 204: + return None + return resp.json() + + def patch(self, path: str, *, json: dict[str, Any]) -> Any: + resp = self._client.patch(path, json=json) + _raise_for_status(resp) + return resp.json() + + def delete(self, path: str) -> None: + resp = self._client.delete(path) + _raise_for_status(resp) + + def close(self) -> None: + self._client.close() + + def __enter__(self) -> HttpTransport: + return self + + def __exit__(self, *_: object) -> None: + self.close() + + +def _raise_for_status(resp: httpx.Response) -> None: + if resp.status_code < 400: + return + try: + detail = resp.json().get("detail", resp.text) + except Exception: + detail = resp.text + raise InkboxAPIError(status_code=resp.status_code, detail=str(detail)) diff --git a/python/inkbox/identities/exceptions.py b/python/inkbox/identities/exceptions.py new file mode 100644 index 0000000..2a90826 --- /dev/null +++ b/python/inkbox/identities/exceptions.py @@ -0,0 +1,25 @@ +""" +inkbox/identities/exceptions.py + +Exception types raised by the SDK. +""" + +from __future__ import annotations + + +class InkboxError(Exception): + """Base exception for all Inkbox SDK errors.""" + + +class InkboxAPIError(InkboxError): + """Raised when the API returns a 4xx or 5xx response. + + Attributes: + status_code: HTTP status code. + detail: Error detail from the response body. + """ + + def __init__(self, status_code: int, detail: str) -> None: + super().__init__(f"HTTP {status_code}: {detail}") + self.status_code = status_code + self.detail = detail diff --git a/python/inkbox/identities/resources/__init__.py b/python/inkbox/identities/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/inkbox/identities/resources/identities.py b/python/inkbox/identities/resources/identities.py new file mode 100644 index 0000000..06343a0 --- /dev/null +++ b/python/inkbox/identities/resources/identities.py @@ -0,0 +1,133 @@ +""" +inkbox/identities/resources/identities.py + +Identity CRUD and channel assignment. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from uuid import UUID + +from inkbox.identities.types import AgentIdentitySummary, _AgentIdentityData + +if TYPE_CHECKING: + from inkbox.identities._http import HttpTransport + + +class IdentitiesResource: + def __init__(self, http: HttpTransport) -> None: + self._http = http + + def create(self, *, agent_handle: str) -> AgentIdentitySummary: + """Create a new agent identity. + + Args: + agent_handle: Unique handle for this identity within your organisation + (e.g. ``"sales-agent"`` or ``"@sales-agent"``). + + Returns: + The created identity. + """ + data = self._http.post("/", json={"agent_handle": agent_handle}) + return AgentIdentitySummary._from_dict(data) + + def list(self) -> list[AgentIdentitySummary]: + """List all identities for your organisation.""" + data = self._http.get("/") + return [AgentIdentitySummary._from_dict(i) for i in data] + + def get(self, agent_handle: str) -> _AgentIdentityData: + """Get an identity with its linked channels (mailbox, phone number). + + Args: + agent_handle: Handle of the identity to fetch. + """ + data = self._http.get(f"/{agent_handle}") + return _AgentIdentityData._from_dict(data) + + def update( + self, + agent_handle: str, + *, + new_handle: str | None = None, + status: str | None = None, + ) -> AgentIdentitySummary: + """Update an identity's handle or status. + + Only provided fields are applied; omitted fields are left unchanged. + + Args: + agent_handle: Current handle of the identity to update. + new_handle: New handle value. + status: New lifecycle status: ``"active"`` or ``"paused"``. + """ + body: dict[str, Any] = {} + if new_handle is not None: + body["agent_handle"] = new_handle + if status is not None: + body["status"] = status + data = self._http.patch(f"/{agent_handle}", json=body) + return AgentIdentitySummary._from_dict(data) + + def delete(self, agent_handle: str) -> None: + """Soft-delete an identity. + + Unlinks any assigned channels without deleting them. + + Args: + agent_handle: Handle of the identity to delete. + """ + self._http.delete(f"/{agent_handle}") + + def assign_mailbox( + self, + agent_handle: str, + *, + mailbox_id: UUID | str, + ) -> _AgentIdentityData: + """Assign a mailbox to an identity. + + Args: + agent_handle: Handle of the identity. + mailbox_id: UUID of the mailbox to assign. + """ + data = self._http.post( + f"/{agent_handle}/mailbox", + json={"mailbox_id": str(mailbox_id)}, + ) + return _AgentIdentityData._from_dict(data) + + def unlink_mailbox(self, agent_handle: str) -> None: + """Unlink the mailbox from an identity (does not delete the mailbox). + + Args: + agent_handle: Handle of the identity. + """ + self._http.delete(f"/{agent_handle}/mailbox") + + def assign_phone_number( + self, + agent_handle: str, + *, + phone_number_id: UUID | str, + ) -> _AgentIdentityData: + """Assign a phone number to an identity. + + Args: + agent_handle: Handle of the identity. + phone_number_id: UUID of the phone number to assign. + """ + data = self._http.post( + f"/{agent_handle}/phone_number", + json={"phone_number_id": str(phone_number_id)}, + ) + return _AgentIdentityData._from_dict(data) + + def unlink_phone_number(self, agent_handle: str) -> None: + """Unlink the phone number from an identity (does not delete the number). + + Args: + agent_handle: Handle of the identity. + """ + self._http.delete(f"/{agent_handle}/phone_number") diff --git a/python/inkbox/identities/types.py b/python/inkbox/identities/types.py new file mode 100644 index 0000000..6ebaa36 --- /dev/null +++ b/python/inkbox/identities/types.py @@ -0,0 +1,112 @@ +""" +inkbox/identities/types.py + +Dataclasses mirroring the Inkbox Identities API response models. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any +from uuid import UUID + + +def _dt(value: str | None) -> datetime | None: + return datetime.fromisoformat(value) if value else None + + +@dataclass +class IdentityMailbox: + """Mailbox channel linked to an agent identity.""" + + id: UUID + email_address: str + display_name: str | None + status: str + created_at: datetime + updated_at: datetime + + @classmethod + def _from_dict(cls, d: dict[str, Any]) -> IdentityMailbox: + return cls( + id=UUID(d["id"]), + email_address=d["email_address"], + display_name=d.get("display_name"), + status=d["status"], + created_at=datetime.fromisoformat(d["created_at"]), + updated_at=datetime.fromisoformat(d["updated_at"]), + ) + + +@dataclass +class IdentityPhoneNumber: + """Phone number channel linked to an agent identity.""" + + id: UUID + number: str + type: str + status: str + incoming_call_action: str + client_websocket_url: str | None + created_at: datetime + updated_at: datetime + + @classmethod + def _from_dict(cls, d: dict[str, Any]) -> IdentityPhoneNumber: + return cls( + id=UUID(d["id"]), + number=d["number"], + type=d["type"], + status=d["status"], + incoming_call_action=d["incoming_call_action"], + client_websocket_url=d.get("client_websocket_url"), + created_at=datetime.fromisoformat(d["created_at"]), + updated_at=datetime.fromisoformat(d["updated_at"]), + ) + + +@dataclass +class AgentIdentitySummary: + """Lightweight agent identity returned by list and update endpoints.""" + + id: UUID + organization_id: str + agent_handle: str + status: str + created_at: datetime + updated_at: datetime + + @classmethod + def _from_dict(cls, d: dict[str, Any]) -> AgentIdentitySummary: + return cls( + id=UUID(d["id"]), + organization_id=d["organization_id"], + agent_handle=d["agent_handle"], + status=d["status"], + created_at=datetime.fromisoformat(d["created_at"]), + updated_at=datetime.fromisoformat(d["updated_at"]), + ) + + +@dataclass +class _AgentIdentityData(AgentIdentitySummary): + """Agent identity with linked communication channels. + + Returned by get, assign-mailbox, and assign-phone-number endpoints. + Internal — users interact with AgentIdentity (the domain class) instead. + """ + + mailbox: IdentityMailbox | None = field(default=None) + phone_number: IdentityPhoneNumber | None = field(default=None) + + @classmethod + def _from_dict(cls, d: dict[str, Any]) -> _AgentIdentityData: # type: ignore[override] + base = AgentIdentitySummary._from_dict(d) + mailbox_data = d.get("mailbox") + phone_data = d.get("phone_number") + return cls( + **base.__dict__, + mailbox=IdentityMailbox._from_dict(mailbox_data) if mailbox_data else None, + phone_number=IdentityPhoneNumber._from_dict(phone_data) if phone_data else None, + ) diff --git a/python/inkbox/mail/__init__.py b/python/inkbox/mail/__init__.py index c20c2b6..da7a44f 100644 --- a/python/inkbox/mail/__init__.py +++ b/python/inkbox/mail/__init__.py @@ -1,8 +1,7 @@ """ -inkbox.mail — Python SDK for the Inkbox Mail API. +inkbox.mail — mail types and exceptions. """ -from inkbox.mail.client import InkboxMail from inkbox.mail.exceptions import InkboxAPIError, InkboxError from inkbox.mail.types import ( Mailbox, @@ -10,19 +9,16 @@ MessageDetail, Thread, ThreadDetail, - Webhook, - WebhookCreateResult, ) +from inkbox.signing_keys import SigningKey __all__ = [ - "InkboxMail", "InkboxError", "InkboxAPIError", "Mailbox", "Message", "MessageDetail", + "SigningKey", "Thread", "ThreadDetail", - "Webhook", - "WebhookCreateResult", ] diff --git a/python/inkbox/mail/client.py b/python/inkbox/mail/client.py deleted file mode 100644 index be77f1a..0000000 --- a/python/inkbox/mail/client.py +++ /dev/null @@ -1,73 +0,0 @@ -""" -inkbox/mail/client.py - -Top-level InkboxMail client. -""" - -from __future__ import annotations - -from inkbox.mail._http import HttpTransport -from inkbox.mail.resources.mailboxes import MailboxesResource -from inkbox.mail.resources.messages import MessagesResource -from inkbox.mail.resources.threads import ThreadsResource -from inkbox.mail.resources.webhooks import WebhooksResource - -_DEFAULT_BASE_URL = "https://api.inkbox.ai/api/v1/mail" - - -class InkboxMail: - """Client for the Inkbox Mail API. - - Args: - api_key: Your Inkbox API key (``X-Service-Token``). - base_url: Override the API base URL (useful for self-hosting or testing). - timeout: Request timeout in seconds (default 30). - - Example:: - - from inkbox.mail import InkboxMail - - client = InkboxMail(api_key="sk-...") - - mailbox = client.mailboxes.create(display_name="Agent 01") - - client.messages.send( - mailbox.id, - to=["user@example.com"], - subject="Hello from Inkbox", - body_text="Hi there!", - ) - - for msg in client.messages.list(mailbox.id): - print(msg.subject, msg.from_address) - - client.close() - - The client can also be used as a context manager:: - - with InkboxMail(api_key="sk-...") as client: - mailboxes = client.mailboxes.list() - """ - - def __init__( - self, - api_key: str, - *, - base_url: str = _DEFAULT_BASE_URL, - timeout: float = 30.0, - ) -> None: - self._http = HttpTransport(api_key=api_key, base_url=base_url, timeout=timeout) - self.mailboxes = MailboxesResource(self._http) - self.messages = MessagesResource(self._http) - self.threads = ThreadsResource(self._http) - self.webhooks = WebhooksResource(self._http) - - def close(self) -> None: - """Close the underlying HTTP connection pool.""" - self._http.close() - - def __enter__(self) -> InkboxMail: - return self - - def __exit__(self, *_: object) -> None: - self.close() diff --git a/python/inkbox/mail/resources/__init__.py b/python/inkbox/mail/resources/__init__.py index a2cb00b..ae8610a 100644 --- a/python/inkbox/mail/resources/__init__.py +++ b/python/inkbox/mail/resources/__init__.py @@ -1,11 +1,9 @@ from inkbox.mail.resources.mailboxes import MailboxesResource from inkbox.mail.resources.messages import MessagesResource from inkbox.mail.resources.threads import ThreadsResource -from inkbox.mail.resources.webhooks import WebhooksResource __all__ = [ "MailboxesResource", "MessagesResource", "ThreadsResource", - "WebhooksResource", ] diff --git a/python/inkbox/mail/resources/mailboxes.py b/python/inkbox/mail/resources/mailboxes.py index 2e9d682..559cbce 100644 --- a/python/inkbox/mail/resources/mailboxes.py +++ b/python/inkbox/mail/resources/mailboxes.py @@ -7,7 +7,6 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any -from uuid import UUID from inkbox.mail.types import Mailbox, Message @@ -15,6 +14,7 @@ from inkbox.mail._http import HttpTransport _BASE = "/mailboxes" +_UNSET = object() class MailboxesResource: @@ -47,18 +47,55 @@ def list(self) -> list[Mailbox]: data = self._http.get(_BASE) return [Mailbox._from_dict(m) for m in data] - def get(self, mailbox_id: UUID | str) -> Mailbox: - """Get a mailbox by ID.""" - data = self._http.get(f"{_BASE}/{mailbox_id}") + def get(self, email_address: str) -> Mailbox: + """Get a mailbox by its email address. + + Args: + email_address: Full email address of the mailbox + (e.g. ``"abc-xyz@inkboxmail.com"``). + """ + data = self._http.get(f"{_BASE}/{email_address}") return Mailbox._from_dict(data) - def delete(self, mailbox_id: UUID | str) -> None: - """Delete a mailbox.""" - self._http.delete(f"{_BASE}/{mailbox_id}") + def update( + self, + email_address: str, + *, + display_name: str | None = _UNSET, # type: ignore[assignment] + webhook_url: str | None = _UNSET, # type: ignore[assignment] + ) -> Mailbox: + """Update mutable mailbox fields. + + Only provided fields are applied; omitted fields are left unchanged. + Pass ``webhook_url=None`` to unsubscribe from webhooks. + + Args: + email_address: Full email address of the mailbox to update. + display_name: New human-readable sender name. + webhook_url: HTTPS URL to receive webhook events, or ``None`` to unsubscribe. + + Returns: + The updated mailbox. + """ + body: dict[str, Any] = {} + if display_name is not _UNSET: + body["display_name"] = display_name + if webhook_url is not _UNSET: + body["webhook_url"] = webhook_url + data = self._http.patch(f"{_BASE}/{email_address}", json=body) + return Mailbox._from_dict(data) + + def delete(self, email_address: str) -> None: + """Delete a mailbox. + + Args: + email_address: Full email address of the mailbox to delete. + """ + self._http.delete(f"{_BASE}/{email_address}") def search( self, - mailbox_id: UUID | str, + email_address: str, *, q: str, limit: int = 50, @@ -66,7 +103,7 @@ def search( """Full-text search across messages in a mailbox. Args: - mailbox_id: UUID of the mailbox to search. + email_address: Full email address of the mailbox to search. q: Search query string. limit: Maximum number of results (1–100). @@ -74,7 +111,7 @@ def search( Matching messages ranked by relevance. """ data = self._http.get( - f"{_BASE}/{mailbox_id}/search", + f"{_BASE}/{email_address}/search", params={"q": q, "limit": limit}, ) return [Message._from_dict(m) for m in data["items"]] diff --git a/python/inkbox/mail/resources/messages.py b/python/inkbox/mail/resources/messages.py index 86fe94d..f720693 100644 --- a/python/inkbox/mail/resources/messages.py +++ b/python/inkbox/mail/resources/messages.py @@ -23,36 +23,42 @@ def __init__(self, http: HttpTransport) -> None: def list( self, - mailbox_id: UUID | str, + email_address: str, *, page_size: int = _DEFAULT_PAGE_SIZE, + direction: str | None = None, ) -> Iterator[Message]: """Iterator over all messages in a mailbox, newest first. Pagination is handled automatically — just iterate. Args: - mailbox_id: UUID of the mailbox. + email_address: Full email address of the mailbox. page_size: Number of messages fetched per API call (1–100). + direction: Filter by direction: ``"inbound"`` or ``"outbound"``. Example:: - for msg in client.messages.list(mailbox_id): + for msg in client.messages.list(email_address): print(msg.subject, msg.from_address) """ - return self._paginate(mailbox_id, page_size=page_size) + return self._paginate(email_address, page_size=page_size, direction=direction) def _paginate( self, - mailbox_id: UUID | str, + email_address: str, *, page_size: int, + direction: str | None = None, ) -> Iterator[Message]: cursor: str | None = None while True: + params: dict[str, Any] = {"limit": page_size, "cursor": cursor} + if direction is not None: + params["direction"] = direction page = self._http.get( - f"/mailboxes/{mailbox_id}/messages", - params={"limit": page_size, "cursor": cursor}, + f"/mailboxes/{email_address}/messages", + params=params, ) for item in page["items"]: yield Message._from_dict(item) @@ -60,22 +66,22 @@ def _paginate( break cursor = page["next_cursor"] - def get(self, mailbox_id: UUID | str, message_id: UUID | str) -> MessageDetail: + def get(self, email_address: str, message_id: UUID | str) -> MessageDetail: """Get a message with full body content. Args: - mailbox_id: UUID of the owning mailbox. + email_address: Full email address of the owning mailbox. message_id: UUID of the message. Returns: Full message including ``body_text`` and ``body_html``. """ - data = self._http.get(f"/mailboxes/{mailbox_id}/messages/{message_id}") + data = self._http.get(f"/mailboxes/{email_address}/messages/{message_id}") return MessageDetail._from_dict(data) def send( self, - mailbox_id: UUID | str, + email_address: str, *, to: list[str], subject: str, @@ -89,7 +95,7 @@ def send( """Send an email from a mailbox. Args: - mailbox_id: UUID of the sending mailbox. + email_address: Full email address of the sending mailbox. to: Primary recipient addresses (at least one required). subject: Email subject line. body_text: Plain-text body. @@ -122,12 +128,12 @@ def send( if attachments is not None: body["attachments"] = attachments - data = self._http.post(f"/mailboxes/{mailbox_id}/messages", json=body) + data = self._http.post(f"/mailboxes/{email_address}/messages", json=body) return Message._from_dict(data) def update_flags( self, - mailbox_id: UUID | str, + email_address: str, message_id: UUID | str, *, is_read: bool | None = None, @@ -143,26 +149,52 @@ def update_flags( if is_starred is not None: body["is_starred"] = is_starred data = self._http.patch( - f"/mailboxes/{mailbox_id}/messages/{message_id}", json=body + f"/mailboxes/{email_address}/messages/{message_id}", json=body ) return Message._from_dict(data) - def mark_read(self, mailbox_id: UUID | str, message_id: UUID | str) -> Message: + def mark_read(self, email_address: str, message_id: UUID | str) -> Message: """Mark a message as read.""" - return self.update_flags(mailbox_id, message_id, is_read=True) + return self.update_flags(email_address, message_id, is_read=True) - def mark_unread(self, mailbox_id: UUID | str, message_id: UUID | str) -> Message: + def mark_unread(self, email_address: str, message_id: UUID | str) -> Message: """Mark a message as unread.""" - return self.update_flags(mailbox_id, message_id, is_read=False) + return self.update_flags(email_address, message_id, is_read=False) - def star(self, mailbox_id: UUID | str, message_id: UUID | str) -> Message: + def star(self, email_address: str, message_id: UUID | str) -> Message: """Star a message.""" - return self.update_flags(mailbox_id, message_id, is_starred=True) + return self.update_flags(email_address, message_id, is_starred=True) - def unstar(self, mailbox_id: UUID | str, message_id: UUID | str) -> Message: + def unstar(self, email_address: str, message_id: UUID | str) -> Message: """Unstar a message.""" - return self.update_flags(mailbox_id, message_id, is_starred=False) + return self.update_flags(email_address, message_id, is_starred=False) - def delete(self, mailbox_id: UUID | str, message_id: UUID | str) -> None: + def delete(self, email_address: str, message_id: UUID | str) -> None: """Delete a message.""" - self._http.delete(f"/mailboxes/{mailbox_id}/messages/{message_id}") + self._http.delete(f"/mailboxes/{email_address}/messages/{message_id}") + + def get_attachment( + self, + email_address: str, + message_id: UUID | str, + filename: str, + *, + redirect: bool = False, + ) -> dict[str, Any]: + """Get a presigned URL for a message attachment. + + Args: + email_address: Full email address of the owning mailbox. + message_id: UUID of the message. + filename: Attachment filename. + redirect: If ``True``, follows the 302 redirect and returns the final + URL as ``{"url": str}``. If ``False`` (default), returns + ``{"url": str, "filename": str, "expires_in": int}``. + + Returns: + Dict with ``url``, ``filename``, and ``expires_in`` (seconds). + """ + return self._http.get( + f"/mailboxes/{email_address}/messages/{message_id}/attachments/{filename}", + params={"redirect": "true" if redirect else "false"}, + ) diff --git a/python/inkbox/mail/resources/threads.py b/python/inkbox/mail/resources/threads.py index 9e1ec67..2cb124f 100644 --- a/python/inkbox/mail/resources/threads.py +++ b/python/inkbox/mail/resources/threads.py @@ -23,7 +23,7 @@ def __init__(self, http: HttpTransport) -> None: def list( self, - mailbox_id: UUID | str, + email_address: str, *, page_size: int = _DEFAULT_PAGE_SIZE, ) -> Iterator[Thread]: @@ -32,26 +32,26 @@ def list( Pagination is handled automatically — just iterate. Args: - mailbox_id: UUID of the mailbox. + email_address: Full email address of the mailbox. page_size: Number of threads fetched per API call (1–100). Example:: - for thread in client.threads.list(mailbox_id): + for thread in client.threads.list(email_address): print(thread.subject, thread.message_count) """ - return self._paginate(mailbox_id, page_size=page_size) + return self._paginate(email_address, page_size=page_size) def _paginate( self, - mailbox_id: UUID | str, + email_address: str, *, page_size: int, ) -> Iterator[Thread]: cursor: str | None = None while True: page = self._http.get( - f"/mailboxes/{mailbox_id}/threads", + f"/mailboxes/{email_address}/threads", params={"limit": page_size, "cursor": cursor}, ) for item in page["items"]: @@ -60,19 +60,19 @@ def _paginate( break cursor = page["next_cursor"] - def get(self, mailbox_id: UUID | str, thread_id: UUID | str) -> ThreadDetail: + def get(self, email_address: str, thread_id: UUID | str) -> ThreadDetail: """Get a thread with all its messages inlined. Args: - mailbox_id: UUID of the owning mailbox. + email_address: Full email address of the owning mailbox. thread_id: UUID of the thread. Returns: Thread detail with all messages (oldest-first). """ - data = self._http.get(f"/mailboxes/{mailbox_id}/threads/{thread_id}") + data = self._http.get(f"/mailboxes/{email_address}/threads/{thread_id}") return ThreadDetail._from_dict(data) - def delete(self, mailbox_id: UUID | str, thread_id: UUID | str) -> None: + def delete(self, email_address: str, thread_id: UUID | str) -> None: """Delete a thread.""" - self._http.delete(f"/mailboxes/{mailbox_id}/threads/{thread_id}") + self._http.delete(f"/mailboxes/{email_address}/threads/{thread_id}") diff --git a/python/inkbox/mail/resources/webhooks.py b/python/inkbox/mail/resources/webhooks.py deleted file mode 100644 index 5e645ef..0000000 --- a/python/inkbox/mail/resources/webhooks.py +++ /dev/null @@ -1,54 +0,0 @@ -""" -inkbox/mail/resources/webhooks.py - -Webhook CRUD. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING -from uuid import UUID - -from inkbox.mail.types import Webhook, WebhookCreateResult - -if TYPE_CHECKING: - from inkbox.mail._http import HttpTransport - - -class WebhooksResource: - def __init__(self, http: HttpTransport) -> None: - self._http = http - - def create( - self, - mailbox_id: UUID | str, - *, - url: str, - event_types: list[str], - ) -> WebhookCreateResult: - """Register a webhook subscription for a mailbox. - - Args: - mailbox_id: UUID of the mailbox to subscribe to. - url: HTTPS endpoint that will receive webhook POST requests. - event_types: Events to subscribe to. - Valid values: ``"message.received"``, ``"message.sent"``. - - Returns: - The created webhook. ``secret`` is the HMAC-SHA256 signing key — - save it immediately, as it will not be returned again. - """ - data = self._http.post( - f"/mailboxes/{mailbox_id}/webhooks", - json={"url": url, "event_types": event_types}, - ) - return WebhookCreateResult._from_dict(data) - - def list(self, mailbox_id: UUID | str) -> list[Webhook]: - """List all active webhooks for a mailbox.""" - data = self._http.get(f"/mailboxes/{mailbox_id}/webhooks") - return [Webhook._from_dict(w) for w in data] - - def delete(self, mailbox_id: UUID | str, webhook_id: UUID | str) -> None: - """Delete a webhook subscription.""" - self._http.delete(f"/mailboxes/{mailbox_id}/webhooks/{webhook_id}") diff --git a/python/inkbox/mail/types.py b/python/inkbox/mail/types.py index 7af8989..a32f471 100644 --- a/python/inkbox/mail/types.py +++ b/python/inkbox/mail/types.py @@ -23,6 +23,7 @@ class Mailbox: id: UUID email_address: str display_name: str | None + webhook_url: str | None status: str created_at: datetime updated_at: datetime @@ -33,6 +34,7 @@ def _from_dict(cls, d: dict[str, Any]) -> Mailbox: id=UUID(d["id"]), email_address=d["email_address"], display_name=d.get("display_name"), + webhook_url=d.get("webhook_url"), status=d["status"], created_at=datetime.fromisoformat(d["created_at"]), updated_at=datetime.fromisoformat(d["updated_at"]), @@ -153,36 +155,3 @@ def _from_dict(cls, d: dict[str, Any]) -> ThreadDetail: # type: ignore[override ) -@dataclass -class Webhook: - """A webhook subscription for a mailbox.""" - - id: UUID - mailbox_id: UUID - url: str - event_types: list[str] - status: str - created_at: datetime - - @classmethod - def _from_dict(cls, d: dict[str, Any]) -> Webhook: - return cls( - id=UUID(d["id"]), - mailbox_id=UUID(d["mailbox_id"]), - url=d["url"], - event_types=d["event_types"], - status=d["status"], - created_at=datetime.fromisoformat(d["created_at"]), - ) - - -@dataclass -class WebhookCreateResult(Webhook): - """Returned only on webhook creation. Includes the one-time HMAC signing secret.""" - - secret: str = "" - - @classmethod - def _from_dict(cls, d: dict[str, Any]) -> WebhookCreateResult: # type: ignore[override] - base = Webhook._from_dict(d) - return cls(**base.__dict__, secret=d["secret"]) diff --git a/python/inkbox/phone/__init__.py b/python/inkbox/phone/__init__.py new file mode 100644 index 0000000..bd757ec --- /dev/null +++ b/python/inkbox/phone/__init__.py @@ -0,0 +1,24 @@ +""" +inkbox.phone — phone types and exceptions. +""" + +from inkbox.phone.exceptions import InkboxAPIError, InkboxError +from inkbox.phone.types import ( + PhoneCall, + PhoneCallWithRateLimit, + PhoneNumber, + PhoneTranscript, + RateLimitInfo, +) +from inkbox.signing_keys import SigningKey + +__all__ = [ + "InkboxError", + "InkboxAPIError", + "PhoneCall", + "PhoneCallWithRateLimit", + "PhoneNumber", + "PhoneTranscript", + "RateLimitInfo", + "SigningKey", +] diff --git a/python/inkbox/phone/_http.py b/python/inkbox/phone/_http.py new file mode 100644 index 0000000..b03c1cd --- /dev/null +++ b/python/inkbox/phone/_http.py @@ -0,0 +1,68 @@ +""" +inkbox/phone/_http.py + +Sync HTTP transport (internal). +""" + +from __future__ import annotations + +from typing import Any + +import httpx + +from inkbox.phone.exceptions import InkboxAPIError + +_DEFAULT_TIMEOUT = 30.0 + + +class HttpTransport: + def __init__(self, api_key: str, base_url: str, timeout: float = _DEFAULT_TIMEOUT) -> None: + self._client = httpx.Client( + base_url=base_url, + headers={ + "X-Service-Token": api_key, + "Accept": "application/json", + }, + timeout=timeout, + ) + + def get(self, path: str, *, params: dict[str, Any] | None = None) -> Any: + cleaned = {k: v for k, v in (params or {}).items() if v is not None} + resp = self._client.get(path, params=cleaned) + _raise_for_status(resp) + return resp.json() + + def post(self, path: str, *, json: dict[str, Any] | None = None) -> Any: + resp = self._client.post(path, json=json) + _raise_for_status(resp) + if resp.status_code == 204: + return None + return resp.json() + + def patch(self, path: str, *, json: dict[str, Any]) -> Any: + resp = self._client.patch(path, json=json) + _raise_for_status(resp) + return resp.json() + + def delete(self, path: str) -> None: + resp = self._client.delete(path) + _raise_for_status(resp) + + def close(self) -> None: + self._client.close() + + def __enter__(self) -> HttpTransport: + return self + + def __exit__(self, *_: object) -> None: + self.close() + + +def _raise_for_status(resp: httpx.Response) -> None: + if resp.status_code < 400: + return + try: + detail = resp.json().get("detail", resp.text) + except Exception: + detail = resp.text + raise InkboxAPIError(status_code=resp.status_code, detail=str(detail)) diff --git a/python/inkbox/phone/exceptions.py b/python/inkbox/phone/exceptions.py new file mode 100644 index 0000000..6f05a5b --- /dev/null +++ b/python/inkbox/phone/exceptions.py @@ -0,0 +1,25 @@ +""" +inkbox/phone/exceptions.py + +Exception types raised by the SDK. +""" + +from __future__ import annotations + + +class InkboxError(Exception): + """Base exception for all Inkbox SDK errors.""" + + +class InkboxAPIError(InkboxError): + """Raised when the API returns a 4xx or 5xx response. + + Attributes: + status_code: HTTP status code. + detail: Error detail from the response body. + """ + + def __init__(self, status_code: int, detail: str) -> None: + super().__init__(f"HTTP {status_code}: {detail}") + self.status_code = status_code + self.detail = detail diff --git a/python/inkbox/phone/resources/__init__.py b/python/inkbox/phone/resources/__init__.py new file mode 100644 index 0000000..99c4857 --- /dev/null +++ b/python/inkbox/phone/resources/__init__.py @@ -0,0 +1,9 @@ +from inkbox.phone.resources.numbers import PhoneNumbersResource +from inkbox.phone.resources.calls import CallsResource +from inkbox.phone.resources.transcripts import TranscriptsResource + +__all__ = [ + "PhoneNumbersResource", + "CallsResource", + "TranscriptsResource", +] diff --git a/python/inkbox/phone/resources/calls.py b/python/inkbox/phone/resources/calls.py new file mode 100644 index 0000000..374f1b7 --- /dev/null +++ b/python/inkbox/phone/resources/calls.py @@ -0,0 +1,84 @@ +""" +inkbox/phone/resources/calls.py + +Call operations: list, get, place. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from uuid import UUID + +from inkbox.phone.types import PhoneCall, PhoneCallWithRateLimit + +if TYPE_CHECKING: + from inkbox.phone._http import HttpTransport + + +class CallsResource: + def __init__(self, http: HttpTransport) -> None: + self._http = http + + def list( + self, + phone_number_id: UUID | str, + *, + limit: int = 50, + offset: int = 0, + ) -> list[PhoneCall]: + """List calls for a phone number, newest first. + + Args: + phone_number_id: UUID of the phone number. + limit: Max results to return (1–200). + offset: Pagination offset. + """ + data = self._http.get( + f"/numbers/{phone_number_id}/calls", + params={"limit": limit, "offset": offset}, + ) + return [PhoneCall._from_dict(c) for c in data] + + def get( + self, + phone_number_id: UUID | str, + call_id: UUID | str, + ) -> PhoneCall: + """Get a single call by ID. + + Args: + phone_number_id: UUID of the phone number. + call_id: UUID of the call. + """ + data = self._http.get(f"/numbers/{phone_number_id}/calls/{call_id}") + return PhoneCall._from_dict(data) + + def place( + self, + *, + from_number: str, + to_number: str, + client_websocket_url: str | None = None, + webhook_url: str | None = None, + ) -> PhoneCallWithRateLimit: + """Place an outbound call. + + Args: + from_number: E.164 number to call from. Must belong to your org and be active. + to_number: E.164 number to call. + client_websocket_url: WebSocket URL (wss://) for audio bridging. + webhook_url: Custom webhook URL for call lifecycle events. + + Returns: + The created call record with current rate limit info. + """ + body: dict[str, Any] = { + "from_number": from_number, + "to_number": to_number, + } + if client_websocket_url is not None: + body["client_websocket_url"] = client_websocket_url + if webhook_url is not None: + body["webhook_url"] = webhook_url + data = self._http.post("/place-call", json=body) + return PhoneCallWithRateLimit._from_dict(data) diff --git a/python/inkbox/phone/resources/numbers.py b/python/inkbox/phone/resources/numbers.py new file mode 100644 index 0000000..744f366 --- /dev/null +++ b/python/inkbox/phone/resources/numbers.py @@ -0,0 +1,115 @@ +""" +inkbox/phone/resources/numbers.py + +Phone number CRUD, provisioning, release, and transcript search. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from uuid import UUID + +from inkbox.phone.types import PhoneNumber, PhoneTranscript + +if TYPE_CHECKING: + from inkbox.phone._http import HttpTransport + +_BASE = "/numbers" +_UNSET = object() + + +class PhoneNumbersResource: + def __init__(self, http: HttpTransport) -> None: + self._http = http + + def list(self) -> list[PhoneNumber]: + """List all phone numbers for your organisation.""" + data = self._http.get(_BASE) + return [PhoneNumber._from_dict(n) for n in data] + + def get(self, phone_number_id: UUID | str) -> PhoneNumber: + """Get a phone number by ID.""" + data = self._http.get(f"{_BASE}/{phone_number_id}") + return PhoneNumber._from_dict(data) + + def update( + self, + phone_number_id: UUID | str, + *, + incoming_call_action: str | None = _UNSET, # type: ignore[assignment] + client_websocket_url: str | None = _UNSET, # type: ignore[assignment] + incoming_call_webhook_url: str | None = _UNSET, # type: ignore[assignment] + ) -> PhoneNumber: + """Update phone number settings. + + Pass only the fields you want to change; omitted fields are left as-is. + Pass a field as ``None`` to clear it. + + Args: + phone_number_id: UUID of the phone number. + incoming_call_action: ``"auto_accept"``, ``"auto_reject"``, or ``"webhook"``. + client_websocket_url: WebSocket URL (wss://) for audio bridging. + incoming_call_webhook_url: Webhook URL called for incoming calls when action is ``"webhook"``. + """ + body: dict[str, Any] = {} + if incoming_call_action is not _UNSET: + body["incoming_call_action"] = incoming_call_action + if client_websocket_url is not _UNSET: + body["client_websocket_url"] = client_websocket_url + if incoming_call_webhook_url is not _UNSET: + body["incoming_call_webhook_url"] = incoming_call_webhook_url + data = self._http.patch(f"{_BASE}/{phone_number_id}", json=body) + return PhoneNumber._from_dict(data) + + def provision( + self, + *, + agent_handle: str, + type: str = "toll_free", + state: str | None = None, + ) -> PhoneNumber: + """Provision a new phone number and link it to an agent identity. + + Args: + agent_handle: Handle of the agent identity to assign this number to. + type: ``"toll_free"`` or ``"local"``. Defaults to ``"toll_free"``. + state: US state abbreviation (e.g. ``"NY"``). Only valid for ``local`` numbers. + + Returns: + The provisioned phone number. + """ + body: dict[str, Any] = {"agent_handle": agent_handle, "type": type} + if state is not None: + body["state"] = state + data = self._http.post(_BASE, json=body) + return PhoneNumber._from_dict(data) + + def release(self, phone_number_id: UUID | str) -> None: + """Release a phone number. + + Args: + phone_number_id: UUID of the phone number to release. + """ + self._http.delete(f"{_BASE}/{phone_number_id}") + + def search_transcripts( + self, + phone_number_id: UUID | str, + *, + q: str, + party: str | None = None, + limit: int = 50, + ) -> list[PhoneTranscript]: + """Full-text search across transcripts for a phone number. + + Args: + phone_number_id: UUID of the phone number. + q: Search query string. + party: Filter by speaker: ``"local"`` or ``"remote"``. + limit: Maximum number of results (1–200). + """ + data = self._http.get( + f"{_BASE}/{phone_number_id}/search", + params={"q": q, "party": party, "limit": limit}, + ) + return [PhoneTranscript._from_dict(t) for t in data] diff --git a/python/inkbox/phone/resources/transcripts.py b/python/inkbox/phone/resources/transcripts.py new file mode 100644 index 0000000..6ae0ad2 --- /dev/null +++ b/python/inkbox/phone/resources/transcripts.py @@ -0,0 +1,36 @@ +""" +inkbox/phone/resources/transcripts.py + +Transcript retrieval. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +from inkbox.phone.types import PhoneTranscript + +if TYPE_CHECKING: + from inkbox.phone._http import HttpTransport + + +class TranscriptsResource: + def __init__(self, http: HttpTransport) -> None: + self._http = http + + def list( + self, + phone_number_id: UUID | str, + call_id: UUID | str, + ) -> list[PhoneTranscript]: + """List all transcript segments for a call, ordered by sequence number. + + Args: + phone_number_id: UUID of the phone number. + call_id: UUID of the call. + """ + data = self._http.get( + f"/numbers/{phone_number_id}/calls/{call_id}/transcripts", + ) + return [PhoneTranscript._from_dict(t) for t in data] diff --git a/python/inkbox/phone/types.py b/python/inkbox/phone/types.py new file mode 100644 index 0000000..8cda2fd --- /dev/null +++ b/python/inkbox/phone/types.py @@ -0,0 +1,150 @@ +""" +inkbox/phone/types.py + +Dataclasses mirroring the Inkbox Phone API response models. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Any +from uuid import UUID + + +def _dt(value: str | None) -> datetime | None: + return datetime.fromisoformat(value) if value else None + + +@dataclass +class PhoneNumber: + """A phone number owned by your organisation.""" + + id: UUID + number: str + type: str + status: str + incoming_call_action: str + client_websocket_url: str | None + incoming_call_webhook_url: str | None + created_at: datetime + updated_at: datetime + + @classmethod + def _from_dict(cls, d: dict[str, Any]) -> PhoneNumber: + return cls( + id=UUID(d["id"]), + number=d["number"], + type=d["type"], + status=d["status"], + incoming_call_action=d["incoming_call_action"], + client_websocket_url=d.get("client_websocket_url"), + incoming_call_webhook_url=d.get("incoming_call_webhook_url"), + created_at=datetime.fromisoformat(d["created_at"]), + updated_at=datetime.fromisoformat(d["updated_at"]), + ) + + +@dataclass +class PhoneCall: + """A phone call record.""" + + id: UUID + local_phone_number: str + remote_phone_number: str + direction: str + status: str + client_websocket_url: str | None + use_inkbox_tts: bool | None + use_inkbox_stt: bool | None + hangup_reason: str | None + started_at: datetime | None + ended_at: datetime | None + created_at: datetime + updated_at: datetime + + @classmethod + def _from_dict(cls, d: dict[str, Any]) -> PhoneCall: + return cls( + id=UUID(d["id"]), + local_phone_number=d["local_phone_number"], + remote_phone_number=d["remote_phone_number"], + direction=d["direction"], + status=d["status"], + client_websocket_url=d.get("client_websocket_url"), + use_inkbox_tts=d.get("use_inkbox_tts"), + use_inkbox_stt=d.get("use_inkbox_stt"), + hangup_reason=d.get("hangup_reason"), + started_at=_dt(d.get("started_at")), + ended_at=_dt(d.get("ended_at")), + created_at=datetime.fromisoformat(d["created_at"]), + updated_at=datetime.fromisoformat(d["updated_at"]), + ) + + +@dataclass +class RateLimitInfo: + """Rolling 24-hour rate limit snapshot for an organisation.""" + + calls_used: int + calls_remaining: int + calls_limit: int + minutes_used: float + minutes_remaining: float + minutes_limit: int + + @classmethod + def _from_dict(cls, d: dict[str, Any]) -> RateLimitInfo: + return cls( + calls_used=d["calls_used"], + calls_remaining=d["calls_remaining"], + calls_limit=d["calls_limit"], + minutes_used=d["minutes_used"], + minutes_remaining=d["minutes_remaining"], + minutes_limit=d["minutes_limit"], + ) + + +@dataclass +class PhoneCallWithRateLimit(PhoneCall): + """PhoneCall extended with the caller's current rate limit snapshot. + + Returned by the place-call endpoint. + """ + + rate_limit: RateLimitInfo = None # type: ignore[assignment] + + @classmethod + def _from_dict(cls, d: dict[str, Any]) -> PhoneCallWithRateLimit: # type: ignore[override] + base = PhoneCall._from_dict(d) + return cls( + **base.__dict__, + rate_limit=RateLimitInfo._from_dict(d["rate_limit"]) if d.get("rate_limit") else None, + ) + + +@dataclass +class PhoneTranscript: + """A transcript segment from a phone call.""" + + id: UUID + call_id: UUID + seq: int + ts_ms: int + party: str + text: str + created_at: datetime + + @classmethod + def _from_dict(cls, d: dict[str, Any]) -> PhoneTranscript: + return cls( + id=UUID(d["id"]), + call_id=UUID(d["call_id"]), + seq=d["seq"], + ts_ms=d["ts_ms"], + party=d["party"], + text=d["text"], + created_at=datetime.fromisoformat(d["created_at"]), + ) + + diff --git a/python/inkbox/signing_keys.py b/python/inkbox/signing_keys.py new file mode 100644 index 0000000..df57927 --- /dev/null +++ b/python/inkbox/signing_keys.py @@ -0,0 +1,81 @@ +""" +inkbox/signing_keys.py + +Org-level webhook signing key management — shared across all Inkbox clients. +""" + +from __future__ import annotations + +import hashlib +import hmac +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Mapping + + +@dataclass +class SigningKey: + """Org-level webhook signing key. + + Returned once on creation/rotation — store ``signing_key`` securely. + """ + + signing_key: str + created_at: datetime + + @classmethod + def _from_dict(cls, d: dict[str, Any]) -> SigningKey: + return cls( + signing_key=d["signing_key"], + created_at=datetime.fromisoformat(d["created_at"]), + ) + + +def verify_webhook( + *, + payload: bytes, + headers: Mapping[str, str], + secret: str, +) -> bool: + """Verify that an incoming webhook request was sent by Inkbox. + + Args: + payload: Raw request body bytes (do not parse/re-serialize). + headers: Request headers mapping (keys are lowercased internally). + secret: Your signing key, with or without a ``whsec_`` prefix. + + Returns: + True if the signature is valid, False otherwise. + """ + h = {k.lower(): v for k, v in headers.items()} + signature = h.get("x-inkbox-signature", "") + request_id = h.get("x-inkbox-request-id", "") + timestamp = h.get("x-inkbox-timestamp", "") + if not signature.startswith("sha256="): + return False + key = secret.removeprefix("whsec_") + message = f"{request_id}.{timestamp}.".encode() + payload + expected = hmac.new(key.encode(), message, hashlib.sha256).hexdigest() + received = signature.removeprefix("sha256=") + return hmac.compare_digest(expected, received) + + +class SigningKeysResource: + def __init__(self, http: Any) -> None: + self._http = http + + def create_or_rotate(self) -> SigningKey: + """Create or rotate the webhook signing key for your organisation. + + The first call creates a new key; subsequent calls rotate (replace) the + existing key. The plaintext ``signing_key`` is returned **once** — + store it securely as it cannot be retrieved again. + + Use the returned key to verify ``X-Inkbox-Signature`` headers on + incoming webhook requests. + + Returns: + The newly created/rotated signing key with its creation timestamp. + """ + data = self._http.post("/signing-keys", json={}) + return SigningKey._from_dict(data) diff --git a/python/publish.sh b/python/publish.sh index dd0a90b..8b7207b 100755 --- a/python/publish.sh +++ b/python/publish.sh @@ -16,14 +16,16 @@ fi echo "==> Cleaning previous builds" rm -rf dist/ build/ *.egg-info -echo "==> Installing build tools" -python -m pip install --quiet build twine - echo "==> Building" -python -m build +uv build echo "==> Uploading to $REPO" -twine upload --repository "$REPO" dist/* +if [[ "$REPO" == "testpypi" ]]; then + REPO_URL="https://test.pypi.org/legacy/" +else + REPO_URL="https://upload.pypi.org/legacy/" +fi +uv run --with twine twine upload --repository-url "$REPO_URL" dist/* echo "" if [[ "$REPO" == "testpypi" ]]; then diff --git a/python/pyproject.toml b/python/pyproject.toml index 7b965b0..8ae0d64 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,13 +4,21 @@ build-backend = "hatchling.build" [project] name = "inkbox" -version = "0.1.0" +version = "0.1.0.2" description = "Python SDK for the Inkbox API" readme = "README.md" requires-python = ">=3.11" license = { text = "MIT" } dependencies = [ "httpx>=0.27", + "python-dotenv>=1.2.2", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-cov>=5.0", + "ruff>=0.4", ] [project.urls] @@ -18,5 +26,9 @@ Homepage = "https://inkbox.ai" Repository = "https://github.com/vectorlyapp/inkbox/tree/main/inkbox/python" "Bug Tracker" = "https://github.com/vectorlyapp/inkbox/issues" +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = [".", "tests"] + [tool.hatch.build.targets.wheel] packages = ["inkbox"] diff --git a/python/tests/__init__.py b/python/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/tests/conftest.py b/python/tests/conftest.py new file mode 100644 index 0000000..47d35ec --- /dev/null +++ b/python/tests/conftest.py @@ -0,0 +1,37 @@ +"""Shared fixtures for Inkbox SDK tests.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from inkbox import Inkbox + + +class FakeHttpTransport: + """Mock HTTP transport that returns pre-configured responses.""" + + def __init__(self) -> None: + self.get = MagicMock() + self.post = MagicMock() + self.patch = MagicMock() + self.delete = MagicMock() + self.close = MagicMock() + + +@pytest.fixture +def transport() -> FakeHttpTransport: + return FakeHttpTransport() + + +@pytest.fixture +def client(transport: FakeHttpTransport) -> Inkbox: + c = Inkbox(api_key="sk-test") + c._phone_http = transport # type: ignore[attr-defined] + c._api_http = transport # type: ignore[attr-defined] + c._numbers._http = transport + c._calls._http = transport + c._transcripts._http = transport + c._signing_keys._http = transport + return c diff --git a/python/tests/sample_data.py b/python/tests/sample_data.py new file mode 100644 index 0000000..79da17d --- /dev/null +++ b/python/tests/sample_data.py @@ -0,0 +1,39 @@ +"""Sample API response dicts for tests.""" + +PHONE_NUMBER_DICT = { + "id": "aaaa1111-0000-0000-0000-000000000001", + "number": "+18335794607", + "type": "toll_free", + "status": "active", + "incoming_call_action": "auto_reject", + "client_websocket_url": None, + "incoming_call_webhook_url": None, + "created_at": "2026-03-09T00:00:00Z", + "updated_at": "2026-03-09T00:00:00Z", +} + +PHONE_CALL_DICT = { + "id": "bbbb2222-0000-0000-0000-000000000001", + "local_phone_number": "+18335794607", + "remote_phone_number": "+15167251294", + "direction": "outbound", + "status": "completed", + "client_websocket_url": "wss://agent.example.com/ws", + "use_inkbox_tts": None, + "use_inkbox_stt": None, + "hangup_reason": None, + "started_at": "2026-03-09T00:01:00Z", + "ended_at": "2026-03-09T00:05:00Z", + "created_at": "2026-03-09T00:00:00Z", + "updated_at": "2026-03-09T00:05:00Z", +} + +PHONE_TRANSCRIPT_DICT = { + "id": "cccc3333-0000-0000-0000-000000000001", + "call_id": "bbbb2222-0000-0000-0000-000000000001", + "seq": 0, + "ts_ms": 1500, + "party": "local", + "text": "Hello, how can I help you?", + "created_at": "2026-03-09T00:01:01Z", +} diff --git a/python/tests/sample_data_identities.py b/python/tests/sample_data_identities.py new file mode 100644 index 0000000..bb0590b --- /dev/null +++ b/python/tests/sample_data_identities.py @@ -0,0 +1,36 @@ +"""Sample API response dicts for identities tests.""" + +IDENTITY_DICT = { + "id": "eeee5555-0000-0000-0000-000000000001", + "organization_id": "org-abc123", + "agent_handle": "sales-agent", + "status": "active", + "created_at": "2026-03-09T00:00:00Z", + "updated_at": "2026-03-09T00:00:00Z", +} + +IDENTITY_MAILBOX_DICT = { + "id": "aaaa1111-0000-0000-0000-000000000001", + "email_address": "sales-agent@inkbox.ai", + "display_name": "Sales Agent", + "status": "active", + "created_at": "2026-03-09T00:00:00Z", + "updated_at": "2026-03-09T00:00:00Z", +} + +IDENTITY_PHONE_DICT = { + "id": "bbbb2222-0000-0000-0000-000000000001", + "number": "+18335794607", + "type": "toll_free", + "status": "active", + "incoming_call_action": "auto_reject", + "client_websocket_url": None, + "created_at": "2026-03-09T00:00:00Z", + "updated_at": "2026-03-09T00:00:00Z", +} + +IDENTITY_DETAIL_DICT = { + **IDENTITY_DICT, + "mailbox": IDENTITY_MAILBOX_DICT, + "phone_number": IDENTITY_PHONE_DICT, +} diff --git a/python/tests/sample_data_mail.py b/python/tests/sample_data_mail.py new file mode 100644 index 0000000..c4413ff --- /dev/null +++ b/python/tests/sample_data_mail.py @@ -0,0 +1,79 @@ +"""Sample API response dicts for mail tests.""" + +MAILBOX_DICT = { + "id": "aaaa1111-0000-0000-0000-000000000001", + "email_address": "agent01@inkbox.ai", + "display_name": "Agent 01", + "status": "active", + "created_at": "2026-03-09T00:00:00Z", + "updated_at": "2026-03-09T00:00:00Z", +} + +MESSAGE_DICT = { + "id": "bbbb2222-0000-0000-0000-000000000001", + "mailbox_id": "aaaa1111-0000-0000-0000-000000000001", + "thread_id": "eeee5555-0000-0000-0000-000000000001", + "message_id": "", + "from_address": "user@example.com", + "to_addresses": ["agent01@inkbox.ai"], + "cc_addresses": None, + "subject": "Hello from test", + "snippet": "Hi there, this is a test message...", + "direction": "inbound", + "status": "delivered", + "is_read": False, + "is_starred": False, + "has_attachments": False, + "created_at": "2026-03-09T00:00:00Z", +} + +MESSAGE_DETAIL_DICT = { + **MESSAGE_DICT, + "body_text": "Hi there, this is a test message body.", + "body_html": "

Hi there, this is a test message body.

", + "bcc_addresses": None, + "in_reply_to": None, + "references": None, + "attachment_metadata": None, + "ses_message_id": "ses-abc123", + "updated_at": "2026-03-09T00:00:00Z", +} + +THREAD_DICT = { + "id": "eeee5555-0000-0000-0000-000000000001", + "mailbox_id": "aaaa1111-0000-0000-0000-000000000001", + "subject": "Hello from test", + "status": "active", + "message_count": 2, + "last_message_at": "2026-03-09T00:05:00Z", + "created_at": "2026-03-09T00:00:00Z", +} + +THREAD_DETAIL_DICT = { + **THREAD_DICT, + "messages": [MESSAGE_DICT], +} + +CURSOR_PAGE_MESSAGES = { + "items": [MESSAGE_DICT], + "next_cursor": None, + "has_more": False, +} + +CURSOR_PAGE_MESSAGES_MULTI = { + "items": [MESSAGE_DICT], + "next_cursor": "cursor-abc", + "has_more": True, +} + +CURSOR_PAGE_THREADS = { + "items": [THREAD_DICT], + "next_cursor": None, + "has_more": False, +} + +CURSOR_PAGE_SEARCH = { + "items": [MESSAGE_DICT], + "next_cursor": None, + "has_more": False, +} diff --git a/python/tests/test_agent_identity.py b/python/tests/test_agent_identity.py new file mode 100644 index 0000000..5c0a3c5 --- /dev/null +++ b/python/tests/test_agent_identity.py @@ -0,0 +1,47 @@ +"""Tests for AgentIdentity convenience methods.""" + +import pytest +from unittest.mock import MagicMock + +from sample_data_identities import IDENTITY_DETAIL_DICT +from sample_data_mail import THREAD_DETAIL_DICT + +from inkbox.agent_identity import AgentIdentity +from inkbox.identities.types import _AgentIdentityData +from inkbox.mail.exceptions import InkboxError +from inkbox.mail.types import ThreadDetail + + +def _identity_with_mailbox(): + """Return an AgentIdentity backed by a mock Inkbox client.""" + data = _AgentIdentityData._from_dict(IDENTITY_DETAIL_DICT) + inkbox = MagicMock() + return AgentIdentity(data, inkbox), inkbox + + +def _identity_without_mailbox(): + """Return an AgentIdentity with no mailbox assigned.""" + detail = {**IDENTITY_DETAIL_DICT, "mailbox": None} + data = _AgentIdentityData._from_dict(detail) + inkbox = MagicMock() + return AgentIdentity(data, inkbox), inkbox + + +class TestAgentIdentityGetThread: + def test_get_thread_returns_thread_detail(self): + identity, inkbox = _identity_with_mailbox() + thread_id = THREAD_DETAIL_DICT["id"] + inkbox._threads.get.return_value = ThreadDetail._from_dict(THREAD_DETAIL_DICT) + + result = identity.get_thread(thread_id) + + inkbox._threads.get.assert_called_once_with("sales-agent@inkbox.ai", thread_id) + assert isinstance(result, ThreadDetail) + assert str(result.id) == thread_id + assert len(result.messages) == 1 + + def test_get_thread_requires_mailbox(self): + identity, _ = _identity_without_mailbox() + + with pytest.raises(InkboxError, match="no mailbox assigned"): + identity.get_thread("eeee5555-0000-0000-0000-000000000001") diff --git a/python/tests/test_calls.py b/python/tests/test_calls.py new file mode 100644 index 0000000..6afb5fb --- /dev/null +++ b/python/tests/test_calls.py @@ -0,0 +1,110 @@ +"""Tests for CallsResource.""" + +from uuid import UUID + +from sample_data import PHONE_CALL_DICT + + +NUM_ID = "aaaa1111-0000-0000-0000-000000000001" +CALL_ID = "bbbb2222-0000-0000-0000-000000000001" + + +class TestCallsList: + def test_returns_calls(self, client, transport): + transport.get.return_value = [PHONE_CALL_DICT] + + calls = client._calls.list(NUM_ID, limit=5) + + transport.get.assert_called_once_with( + f"/numbers/{NUM_ID}/calls", + params={"limit": 5, "offset": 0}, + ) + assert len(calls) == 1 + assert calls[0].direction == "outbound" + assert calls[0].remote_phone_number == "+15167251294" + + def test_default_limit_and_offset(self, client, transport): + transport.get.return_value = [] + + client._calls.list(NUM_ID) + + transport.get.assert_called_once_with( + f"/numbers/{NUM_ID}/calls", + params={"limit": 50, "offset": 0}, + ) + + def test_custom_offset(self, client, transport): + transport.get.return_value = [] + + client._calls.list(NUM_ID, limit=10, offset=20) + + transport.get.assert_called_once_with( + f"/numbers/{NUM_ID}/calls", + params={"limit": 10, "offset": 20}, + ) + + +class TestCallsGet: + def test_returns_call(self, client, transport): + transport.get.return_value = PHONE_CALL_DICT + + call = client._calls.get(NUM_ID, CALL_ID) + + transport.get.assert_called_once_with(f"/numbers/{NUM_ID}/calls/{CALL_ID}") + assert call.id == UUID(CALL_ID) + assert call.status == "completed" + assert call.client_websocket_url == "wss://agent.example.com/ws" + assert call.started_at is not None + assert call.ended_at is not None + + +class TestCallsPlace: + def test_place_outbound_call(self, client, transport): + transport.post.return_value = { + **PHONE_CALL_DICT, + "status": "ringing", + "started_at": None, + "ended_at": None, + } + + call = client._calls.place( + from_number="+18335794607", + to_number="+15167251294", + client_websocket_url="wss://agent.example.com/ws", + ) + + transport.post.assert_called_once_with( + "/place-call", + json={ + "from_number": "+18335794607", + "to_number": "+15167251294", + "client_websocket_url": "wss://agent.example.com/ws", + }, + ) + assert call.status == "ringing" + + def test_place_with_websocket_and_webhook(self, client, transport): + transport.post.return_value = PHONE_CALL_DICT + + client._calls.place( + from_number="+18335794607", + to_number="+15167251294", + client_websocket_url="wss://agent.example.com/ws", + webhook_url="https://example.com/hook", + ) + + _, kwargs = transport.post.call_args + assert kwargs["json"]["client_websocket_url"] == "wss://agent.example.com/ws" + assert kwargs["json"]["webhook_url"] == "https://example.com/hook" + + def test_optional_fields_omitted_when_none(self, client, transport): + transport.post.return_value = PHONE_CALL_DICT + + client._calls.place( + from_number="+18335794607", + to_number="+15167251294", + ) + + _, kwargs = transport.post.call_args + assert "client_websocket_url" not in kwargs["json"] + assert "webhook_url" not in kwargs["json"] diff --git a/python/tests/test_client.py b/python/tests/test_client.py new file mode 100644 index 0000000..91a12a1 --- /dev/null +++ b/python/tests/test_client.py @@ -0,0 +1,24 @@ +"""Tests for Inkbox unified client — phone resources.""" + +from inkbox import Inkbox +from inkbox.phone.resources.numbers import PhoneNumbersResource +from inkbox.phone.resources.calls import CallsResource +from inkbox.phone.resources.transcripts import TranscriptsResource +from inkbox.signing_keys import SigningKeysResource + + +class TestInkboxPhoneResources: + def test_creates_phone_resource_instances(self): + client = Inkbox(api_key="sk-test") + + assert isinstance(client._numbers, PhoneNumbersResource) + assert isinstance(client._calls, CallsResource) + assert isinstance(client._transcripts, TranscriptsResource) + assert isinstance(client._signing_keys, SigningKeysResource) + + client.close() + + def test_phone_http_base_url(self): + client = Inkbox(api_key="sk-test", base_url="http://localhost:8000") + assert str(client._phone_http._client.base_url) == "http://localhost:8000/api/v1/phone/" + client.close() diff --git a/python/tests/test_exceptions.py b/python/tests/test_exceptions.py new file mode 100644 index 0000000..991cc5f --- /dev/null +++ b/python/tests/test_exceptions.py @@ -0,0 +1,32 @@ +"""Tests for SDK exception classes.""" + +from inkbox.phone.exceptions import InkboxAPIError as PhoneAPIError +from inkbox.mail.exceptions import InkboxAPIError as MailAPIError + + +class TestPhoneInkboxAPIError: + def test_message_format(self): + err = PhoneAPIError(404, "not found") + assert str(err) == "HTTP 404: not found" + + def test_attributes(self): + err = PhoneAPIError(422, "validation error") + assert err.status_code == 422 + assert err.detail == "validation error" + + def test_is_exception(self): + assert issubclass(PhoneAPIError, Exception) + + +class TestMailInkboxAPIError: + def test_message_format(self): + err = MailAPIError(500, "server error") + assert str(err) == "HTTP 500: server error" + + def test_attributes(self): + err = MailAPIError(403, "forbidden") + assert err.status_code == 403 + assert err.detail == "forbidden" + + def test_is_exception(self): + assert issubclass(MailAPIError, Exception) diff --git a/python/tests/test_identities.py b/python/tests/test_identities.py new file mode 100644 index 0000000..40a4f66 --- /dev/null +++ b/python/tests/test_identities.py @@ -0,0 +1,144 @@ +"""Tests for IdentitiesResource.""" + +from unittest.mock import MagicMock + +from sample_data_identities import IDENTITY_DICT, IDENTITY_DETAIL_DICT +from inkbox.identities.resources.identities import IdentitiesResource +from inkbox.identities.types import AgentIdentitySummary, _AgentIdentityData + + +def _resource(): + http = MagicMock() + return IdentitiesResource(http), http + + +HANDLE = "sales-agent" + + +class TestIdentitiesCreate: + def test_creates_identity(self): + res, http = _resource() + http.post.return_value = IDENTITY_DICT + + identity = res.create(agent_handle=HANDLE) + + http.post.assert_called_once_with("/", json={"agent_handle": HANDLE}) + assert isinstance(identity, AgentIdentitySummary) + assert identity.agent_handle == HANDLE + + +class TestIdentitiesList: + def test_returns_list(self): + res, http = _resource() + http.get.return_value = [IDENTITY_DICT] + + identities = res.list() + + http.get.assert_called_once_with("/") + assert len(identities) == 1 + assert identities[0].agent_handle == HANDLE + + def test_empty_list(self): + res, http = _resource() + http.get.return_value = [] + + assert res.list() == [] + + +class TestIdentitiesGet: + def test_returns_detail(self): + res, http = _resource() + http.get.return_value = IDENTITY_DETAIL_DICT + + detail = res.get(HANDLE) + + http.get.assert_called_once_with(f"/{HANDLE}") + assert isinstance(detail, _AgentIdentityData) + assert detail.mailbox.email_address == "sales-agent@inkbox.ai" + assert detail.phone_number.number == "+18335794607" + + +class TestIdentitiesUpdate: + def test_update_handle(self): + res, http = _resource() + http.patch.return_value = {**IDENTITY_DICT, "agent_handle": "new-handle"} + + result = res.update(HANDLE, new_handle="new-handle") + + http.patch.assert_called_once_with( + f"/{HANDLE}", json={"agent_handle": "new-handle"} + ) + assert result.agent_handle == "new-handle" + + def test_update_status(self): + res, http = _resource() + http.patch.return_value = {**IDENTITY_DICT, "status": "paused"} + + result = res.update(HANDLE, status="paused") + + http.patch.assert_called_once_with(f"/{HANDLE}", json={"status": "paused"}) + assert result.status == "paused" + + def test_omitted_fields_not_sent(self): + res, http = _resource() + http.patch.return_value = IDENTITY_DICT + + res.update(HANDLE, status="active") + + _, kwargs = http.patch.call_args + assert "agent_handle" not in kwargs["json"] + + +class TestIdentitiesDelete: + def test_deletes_identity(self): + res, http = _resource() + + res.delete(HANDLE) + + http.delete.assert_called_once_with(f"/{HANDLE}") + + +class TestIdentitiesAssignMailbox: + def test_assigns_mailbox(self): + res, http = _resource() + mailbox_id = "aaaa1111-0000-0000-0000-000000000001" + http.post.return_value = IDENTITY_DETAIL_DICT + + detail = res.assign_mailbox(HANDLE, mailbox_id=mailbox_id) + + http.post.assert_called_once_with( + f"/{HANDLE}/mailbox", json={"mailbox_id": mailbox_id} + ) + assert isinstance(detail, _AgentIdentityData) + + +class TestIdentitiesUnlinkMailbox: + def test_unlinks_mailbox(self): + res, http = _resource() + + res.unlink_mailbox(HANDLE) + + http.delete.assert_called_once_with(f"/{HANDLE}/mailbox") + + +class TestIdentitiesAssignPhoneNumber: + def test_assigns_phone_number(self): + res, http = _resource() + phone_id = "bbbb2222-0000-0000-0000-000000000001" + http.post.return_value = IDENTITY_DETAIL_DICT + + detail = res.assign_phone_number(HANDLE, phone_number_id=phone_id) + + http.post.assert_called_once_with( + f"/{HANDLE}/phone_number", json={"phone_number_id": phone_id} + ) + assert isinstance(detail, _AgentIdentityData) + + +class TestIdentitiesUnlinkPhoneNumber: + def test_unlinks_phone_number(self): + res, http = _resource() + + res.unlink_phone_number(HANDLE) + + http.delete.assert_called_once_with(f"/{HANDLE}/phone_number") diff --git a/python/tests/test_identities_client.py b/python/tests/test_identities_client.py new file mode 100644 index 0000000..ad7052a --- /dev/null +++ b/python/tests/test_identities_client.py @@ -0,0 +1,18 @@ +"""Tests for Inkbox unified client — identities.""" + +from inkbox import Inkbox +from inkbox.identities.resources.identities import IdentitiesResource + + +class TestInkboxIdentitiesResources: + def test_creates_identities_resource(self): + client = Inkbox(api_key="sk-test") + + assert isinstance(client._ids_resource, IdentitiesResource) + + client.close() + + def test_ids_http_base_url(self): + client = Inkbox(api_key="sk-test", base_url="http://localhost:8000") + assert str(client._ids_http._client.base_url) == "http://localhost:8000/api/v1/identities/" + client.close() diff --git a/python/tests/test_identities_types.py b/python/tests/test_identities_types.py new file mode 100644 index 0000000..a29b87d --- /dev/null +++ b/python/tests/test_identities_types.py @@ -0,0 +1,71 @@ +"""Tests for identities type parsing.""" + +from datetime import datetime +from uuid import UUID + +from sample_data_identities import ( + IDENTITY_DICT, + IDENTITY_DETAIL_DICT, + IDENTITY_MAILBOX_DICT, + IDENTITY_PHONE_DICT, +) +from inkbox.identities.types import ( + AgentIdentitySummary, + _AgentIdentityData, + IdentityMailbox, + IdentityPhoneNumber, +) + + +class TestAgentIdentitySummaryParsing: + def test_from_dict(self): + i = AgentIdentitySummary._from_dict(IDENTITY_DICT) + + assert isinstance(i.id, UUID) + assert i.organization_id == "org-abc123" + assert i.agent_handle == "sales-agent" + assert i.status == "active" + assert isinstance(i.created_at, datetime) + assert isinstance(i.updated_at, datetime) + + +class TestAgentIdentityDataParsing: + def test_with_channels(self): + d = _AgentIdentityData._from_dict(IDENTITY_DETAIL_DICT) + + assert isinstance(d.id, UUID) + assert d.agent_handle == "sales-agent" + assert isinstance(d.mailbox, IdentityMailbox) + assert d.mailbox.email_address == "sales-agent@inkbox.ai" + assert isinstance(d.phone_number, IdentityPhoneNumber) + assert d.phone_number.number == "+18335794607" + + def test_no_channels(self): + d = _AgentIdentityData._from_dict(IDENTITY_DICT) + + assert d.mailbox is None + assert d.phone_number is None + + +class TestIdentityMailboxParsing: + def test_from_dict(self): + m = IdentityMailbox._from_dict(IDENTITY_MAILBOX_DICT) + + assert isinstance(m.id, UUID) + assert m.email_address == "sales-agent@inkbox.ai" + assert m.display_name == "Sales Agent" + assert m.status == "active" + assert isinstance(m.created_at, datetime) + assert isinstance(m.updated_at, datetime) + + +class TestIdentityPhoneNumberParsing: + def test_from_dict(self): + p = IdentityPhoneNumber._from_dict(IDENTITY_PHONE_DICT) + + assert isinstance(p.id, UUID) + assert p.number == "+18335794607" + assert p.type == "toll_free" + assert p.status == "active" + assert p.incoming_call_action == "auto_reject" + assert p.client_websocket_url is None diff --git a/python/tests/test_mail_client.py b/python/tests/test_mail_client.py new file mode 100644 index 0000000..0a00d92 --- /dev/null +++ b/python/tests/test_mail_client.py @@ -0,0 +1,28 @@ +"""Tests for Inkbox unified client — mail resources.""" + +from inkbox import Inkbox +from inkbox.mail.resources.mailboxes import MailboxesResource +from inkbox.mail.resources.messages import MessagesResource +from inkbox.mail.resources.threads import ThreadsResource +from inkbox.signing_keys import SigningKeysResource + + +class TestInkboxMailResources: + def test_creates_mail_resource_instances(self): + client = Inkbox(api_key="sk-test") + + assert isinstance(client._mailboxes, MailboxesResource) + assert isinstance(client._messages, MessagesResource) + assert isinstance(client._threads, ThreadsResource) + assert isinstance(client._signing_keys, SigningKeysResource) + + client.close() + + def test_context_manager(self): + with Inkbox(api_key="sk-test") as client: + assert isinstance(client, Inkbox) + + def test_mail_http_base_url(self): + client = Inkbox(api_key="sk-test", base_url="http://localhost:8000") + assert str(client._mail_http._client.base_url) == "http://localhost:8000/api/v1/mail/" + client.close() diff --git a/python/tests/test_mail_mailboxes.py b/python/tests/test_mail_mailboxes.py new file mode 100644 index 0000000..5c9299e --- /dev/null +++ b/python/tests/test_mail_mailboxes.py @@ -0,0 +1,128 @@ +"""Tests for MailboxesResource.""" + +from unittest.mock import MagicMock +from uuid import UUID + +from sample_data_mail import MAILBOX_DICT, CURSOR_PAGE_SEARCH +from inkbox.mail.resources.mailboxes import MailboxesResource + + +def _resource(): + http = MagicMock() + return MailboxesResource(http), http + + +class TestMailboxesList: + def test_returns_mailboxes(self): + res, http = _resource() + http.get.return_value = [MAILBOX_DICT] + + mailboxes = res.list() + + http.get.assert_called_once_with("/mailboxes") + assert len(mailboxes) == 1 + assert mailboxes[0].email_address == "agent01@inkbox.ai" + + def test_empty_list(self): + res, http = _resource() + http.get.return_value = [] + + assert res.list() == [] + + +class TestMailboxesGet: + def test_returns_mailbox(self): + res, http = _resource() + http.get.return_value = MAILBOX_DICT + uid = "aaaa1111-0000-0000-0000-000000000001" + + mailbox = res.get(uid) + + http.get.assert_called_once_with(f"/mailboxes/{uid}") + assert mailbox.id == UUID(uid) + assert mailbox.display_name == "Agent 01" + + +class TestMailboxesCreate: + def test_create_with_display_name(self): + res, http = _resource() + http.post.return_value = MAILBOX_DICT + + mailbox = res.create(display_name="Agent 01") + + http.post.assert_called_once_with( + "/mailboxes", json={"display_name": "Agent 01"} + ) + assert mailbox.display_name == "Agent 01" + + def test_create_without_display_name(self): + res, http = _resource() + http.post.return_value = {**MAILBOX_DICT, "display_name": None} + + mailbox = res.create() + + http.post.assert_called_once_with("/mailboxes", json={}) + assert mailbox.display_name is None + + +class TestMailboxesUpdate: + def test_update_display_name(self): + res, http = _resource() + http.patch.return_value = {**MAILBOX_DICT, "display_name": "New Name"} + uid = "aaaa1111-0000-0000-0000-000000000001" + + mailbox = res.update(uid, display_name="New Name") + + http.patch.assert_called_once_with( + f"/mailboxes/{uid}", json={"display_name": "New Name"} + ) + assert mailbox.display_name == "New Name" + + def test_update_omits_none_fields(self): + res, http = _resource() + http.patch.return_value = MAILBOX_DICT + uid = "aaaa1111-0000-0000-0000-000000000001" + + res.update(uid) + + _, kwargs = http.patch.call_args + assert kwargs["json"] == {} + + +class TestMailboxesDelete: + def test_deletes_mailbox(self): + res, http = _resource() + uid = "aaaa1111-0000-0000-0000-000000000001" + + res.delete(uid) + + http.delete.assert_called_once_with(f"/mailboxes/{uid}") + + +class TestMailboxesSearch: + def test_search_returns_messages(self): + res, http = _resource() + http.get.return_value = CURSOR_PAGE_SEARCH + uid = "aaaa1111-0000-0000-0000-000000000001" + + results = res.search(uid, q="invoice") + + http.get.assert_called_once_with( + f"/mailboxes/{uid}/search", + params={"q": "invoice", "limit": 50}, + ) + assert len(results) == 1 + assert results[0].subject == "Hello from test" + + def test_search_with_custom_limit(self): + res, http = _resource() + http.get.return_value = {"items": [], "next_cursor": None, "has_more": False} + uid = "aaaa1111-0000-0000-0000-000000000001" + + results = res.search(uid, q="test", limit=10) + + http.get.assert_called_once_with( + f"/mailboxes/{uid}/search", + params={"q": "test", "limit": 10}, + ) + assert results == [] diff --git a/python/tests/test_mail_messages.py b/python/tests/test_mail_messages.py new file mode 100644 index 0000000..b0a3008 --- /dev/null +++ b/python/tests/test_mail_messages.py @@ -0,0 +1,176 @@ +"""Tests for MessagesResource.""" + +from unittest.mock import MagicMock + +from sample_data_mail import ( + MESSAGE_DICT, + MESSAGE_DETAIL_DICT, + CURSOR_PAGE_MESSAGES, + CURSOR_PAGE_MESSAGES_MULTI, +) +from inkbox.mail.resources.messages import MessagesResource + + +MBOX = "aaaa1111-0000-0000-0000-000000000001" +MSG = "bbbb2222-0000-0000-0000-000000000001" + + +def _resource(): + http = MagicMock() + return MessagesResource(http), http + + +class TestMessagesList: + def test_iterates_single_page(self): + res, http = _resource() + http.get.return_value = CURSOR_PAGE_MESSAGES + + messages = list(res.list(MBOX)) + + assert len(messages) == 1 + assert messages[0].subject == "Hello from test" + + def test_iterates_multiple_pages(self): + res, http = _resource() + page2 = {"items": [MESSAGE_DICT], "next_cursor": None, "has_more": False} + http.get.side_effect = [CURSOR_PAGE_MESSAGES_MULTI, page2] + + messages = list(res.list(MBOX)) + + assert len(messages) == 2 + assert http.get.call_count == 2 + + def test_empty_page(self): + res, http = _resource() + http.get.return_value = {"items": [], "next_cursor": None, "has_more": False} + + messages = list(res.list(MBOX)) + + assert messages == [] + + +class TestMessagesGet: + def test_returns_message_detail(self): + res, http = _resource() + http.get.return_value = MESSAGE_DETAIL_DICT + + detail = res.get(MBOX, MSG) + + http.get.assert_called_once_with(f"/mailboxes/{MBOX}/messages/{MSG}") + assert detail.body_text == "Hi there, this is a test message body." + assert detail.ses_message_id == "ses-abc123" + + +class TestMessagesSend: + def test_send_basic(self): + res, http = _resource() + http.post.return_value = MESSAGE_DICT + + msg = res.send(MBOX, to=["user@example.com"], subject="Test") + + http.post.assert_called_once_with( + f"/mailboxes/{MBOX}/messages", + json={ + "recipients": {"to": ["user@example.com"]}, + "subject": "Test", + }, + ) + assert msg.subject == "Hello from test" + + def test_send_with_all_options(self): + res, http = _resource() + http.post.return_value = MESSAGE_DICT + + res.send( + MBOX, + to=["a@b.com"], + subject="Re: test", + body_text="reply text", + body_html="

reply

", + cc=["cc@b.com"], + bcc=["bcc@b.com"], + in_reply_to_message_id="", + attachments=[{"filename": "f.txt", "content_type": "text/plain", "content_base64": "aGk="}], + ) + + _, kwargs = http.post.call_args + body = kwargs["json"] + assert body["recipients"] == {"to": ["a@b.com"], "cc": ["cc@b.com"], "bcc": ["bcc@b.com"]} + assert body["body_text"] == "reply text" + assert body["body_html"] == "

reply

" + assert body["in_reply_to_message_id"] == "" + assert body["attachments"] == [{"filename": "f.txt", "content_type": "text/plain", "content_base64": "aGk="}] + + def test_optional_fields_omitted(self): + res, http = _resource() + http.post.return_value = MESSAGE_DICT + + res.send(MBOX, to=["a@b.com"], subject="Test") + + _, kwargs = http.post.call_args + body = kwargs["json"] + assert "body_text" not in body + assert "body_html" not in body + assert "cc" not in body["recipients"] + assert "bcc" not in body["recipients"] + assert "in_reply_to_message_id" not in body + assert "attachments" not in body + + +class TestMessagesUpdateFlags: + def test_mark_read(self): + res, http = _resource() + http.patch.return_value = {**MESSAGE_DICT, "is_read": True} + + msg = res.mark_read(MBOX, MSG) + + http.patch.assert_called_once_with( + f"/mailboxes/{MBOX}/messages/{MSG}", + json={"is_read": True}, + ) + assert msg.is_read is True + + def test_mark_unread(self): + res, http = _resource() + http.patch.return_value = MESSAGE_DICT + + res.mark_unread(MBOX, MSG) + + _, kwargs = http.patch.call_args + assert kwargs["json"] == {"is_read": False} + + def test_star(self): + res, http = _resource() + http.patch.return_value = {**MESSAGE_DICT, "is_starred": True} + + res.star(MBOX, MSG) + + _, kwargs = http.patch.call_args + assert kwargs["json"] == {"is_starred": True} + + def test_unstar(self): + res, http = _resource() + http.patch.return_value = MESSAGE_DICT + + res.unstar(MBOX, MSG) + + _, kwargs = http.patch.call_args + assert kwargs["json"] == {"is_starred": False} + + def test_update_both_flags(self): + res, http = _resource() + http.patch.return_value = MESSAGE_DICT + + res.update_flags(MBOX, MSG, is_read=True, is_starred=True) + + _, kwargs = http.patch.call_args + assert kwargs["json"] == {"is_read": True, "is_starred": True} + + +class TestMessagesDelete: + def test_deletes_message(self): + res, http = _resource() + + res.delete(MBOX, MSG) + + http.delete.assert_called_once_with(f"/mailboxes/{MBOX}/messages/{MSG}") diff --git a/python/tests/test_mail_threads.py b/python/tests/test_mail_threads.py new file mode 100644 index 0000000..ce3cb5b --- /dev/null +++ b/python/tests/test_mail_threads.py @@ -0,0 +1,67 @@ +"""Tests for ThreadsResource.""" + +from unittest.mock import MagicMock + +from sample_data_mail import THREAD_DICT, THREAD_DETAIL_DICT, CURSOR_PAGE_THREADS +from inkbox.mail.resources.threads import ThreadsResource + + +MBOX = "aaaa1111-0000-0000-0000-000000000001" +THREAD_ID = "eeee5555-0000-0000-0000-000000000001" + + +def _resource(): + http = MagicMock() + return ThreadsResource(http), http + + +class TestThreadsList: + def test_iterates_single_page(self): + res, http = _resource() + http.get.return_value = CURSOR_PAGE_THREADS + + threads = list(res.list(MBOX)) + + assert len(threads) == 1 + assert threads[0].subject == "Hello from test" + assert threads[0].message_count == 2 + + def test_empty_page(self): + res, http = _resource() + http.get.return_value = {"items": [], "next_cursor": None, "has_more": False} + + threads = list(res.list(MBOX)) + + assert threads == [] + + def test_multi_page(self): + res, http = _resource() + page1 = {"items": [THREAD_DICT], "next_cursor": "cur1", "has_more": True} + page2 = {"items": [THREAD_DICT], "next_cursor": None, "has_more": False} + http.get.side_effect = [page1, page2] + + threads = list(res.list(MBOX)) + + assert len(threads) == 2 + assert http.get.call_count == 2 + + +class TestThreadsGet: + def test_returns_thread_detail(self): + res, http = _resource() + http.get.return_value = THREAD_DETAIL_DICT + + detail = res.get(MBOX, THREAD_ID) + + http.get.assert_called_once_with(f"/mailboxes/{MBOX}/threads/{THREAD_ID}") + assert detail.subject == "Hello from test" + assert len(detail.messages) == 1 + + +class TestThreadsDelete: + def test_deletes_thread(self): + res, http = _resource() + + res.delete(MBOX, THREAD_ID) + + http.delete.assert_called_once_with(f"/mailboxes/{MBOX}/threads/{THREAD_ID}") diff --git a/python/tests/test_mail_types.py b/python/tests/test_mail_types.py new file mode 100644 index 0000000..4a66c29 --- /dev/null +++ b/python/tests/test_mail_types.py @@ -0,0 +1,102 @@ +"""Tests for mail type parsing.""" + +from datetime import datetime +from uuid import UUID + +from sample_data_mail import ( + MAILBOX_DICT, + MESSAGE_DICT, + MESSAGE_DETAIL_DICT, + THREAD_DICT, + THREAD_DETAIL_DICT, +) +from inkbox.mail.types import ( + Mailbox, + Message, + MessageDetail, + Thread, + ThreadDetail, +) + + +class TestMailboxParsing: + def test_from_dict(self): + m = Mailbox._from_dict(MAILBOX_DICT) + + assert isinstance(m.id, UUID) + assert m.email_address == "agent01@inkbox.ai" + assert m.display_name == "Agent 01" + assert m.status == "active" + assert isinstance(m.created_at, datetime) + assert isinstance(m.updated_at, datetime) + + +class TestMessageParsing: + def test_from_dict(self): + m = Message._from_dict(MESSAGE_DICT) + + assert isinstance(m.id, UUID) + assert isinstance(m.mailbox_id, UUID) + assert m.thread_id == UUID("eeee5555-0000-0000-0000-000000000001") + assert m.message_id == "" + assert m.from_address == "user@example.com" + assert m.to_addresses == ["agent01@inkbox.ai"] + assert m.cc_addresses is None + assert m.subject == "Hello from test" + assert m.direction == "inbound" + assert m.is_read is False + assert m.is_starred is False + assert m.has_attachments is False + assert isinstance(m.created_at, datetime) + + def test_null_thread_id(self): + d = {**MESSAGE_DICT, "thread_id": None} + m = Message._from_dict(d) + assert m.thread_id is None + + +class TestMessageDetailParsing: + def test_from_dict(self): + m = MessageDetail._from_dict(MESSAGE_DETAIL_DICT) + + assert m.body_text == "Hi there, this is a test message body." + assert m.body_html == "

Hi there, this is a test message body.

" + assert m.bcc_addresses is None + assert m.in_reply_to is None + assert m.references is None + assert m.attachment_metadata is None + assert m.ses_message_id == "ses-abc123" + assert isinstance(m.updated_at, datetime) + # inherits base fields + assert m.from_address == "user@example.com" + assert m.subject == "Hello from test" + + +class TestThreadParsing: + def test_from_dict(self): + t = Thread._from_dict(THREAD_DICT) + + assert isinstance(t.id, UUID) + assert isinstance(t.mailbox_id, UUID) + assert t.subject == "Hello from test" + assert t.status == "active" + assert t.message_count == 2 + assert isinstance(t.last_message_at, datetime) + assert isinstance(t.created_at, datetime) + + +class TestThreadDetailParsing: + def test_from_dict(self): + t = ThreadDetail._from_dict(THREAD_DETAIL_DICT) + + assert t.subject == "Hello from test" + assert len(t.messages) == 1 + assert isinstance(t.messages[0], Message) + assert t.messages[0].from_address == "user@example.com" + + def test_empty_messages(self): + d = {**THREAD_DICT, "messages": []} + t = ThreadDetail._from_dict(d) + assert t.messages == [] + + diff --git a/python/tests/test_numbers.py b/python/tests/test_numbers.py new file mode 100644 index 0000000..504d443 --- /dev/null +++ b/python/tests/test_numbers.py @@ -0,0 +1,164 @@ +"""Tests for PhoneNumbersResource.""" + +from uuid import UUID + +from sample_data import PHONE_NUMBER_DICT, PHONE_TRANSCRIPT_DICT + + +class TestNumbersList: + def test_returns_list_of_phone_numbers(self, client, transport): + transport.get.return_value = [PHONE_NUMBER_DICT] + + numbers = client._numbers.list() + + transport.get.assert_called_once_with("/numbers") + assert len(numbers) == 1 + assert numbers[0].number == "+18335794607" + assert numbers[0].type == "toll_free" + assert numbers[0].status == "active" + assert numbers[0].client_websocket_url is None + + def test_empty_list(self, client, transport): + transport.get.return_value = [] + + numbers = client._numbers.list() + + assert numbers == [] + + +class TestNumbersGet: + def test_returns_phone_number(self, client, transport): + transport.get.return_value = PHONE_NUMBER_DICT + uid = "aaaa1111-0000-0000-0000-000000000001" + + number = client._numbers.get(uid) + + transport.get.assert_called_once_with(f"/numbers/{uid}") + assert number.id == UUID(uid) + assert number.number == "+18335794607" + assert number.incoming_call_action == "auto_reject" + + +class TestNumbersUpdate: + def test_update_incoming_call_action(self, client, transport): + updated = {**PHONE_NUMBER_DICT, "incoming_call_action": "webhook"} + transport.patch.return_value = updated + uid = "aaaa1111-0000-0000-0000-000000000001" + + result = client._numbers.update(uid, incoming_call_action="webhook") + + transport.patch.assert_called_once_with( + f"/numbers/{uid}", + json={"incoming_call_action": "webhook"}, + ) + assert result.incoming_call_action == "webhook" + + def test_update_multiple_fields(self, client, transport): + updated = { + **PHONE_NUMBER_DICT, + "incoming_call_action": "webhook", + "client_websocket_url": "wss://agent.example.com/ws", + "incoming_call_webhook_url": "https://example.com/hook", + } + transport.patch.return_value = updated + uid = "aaaa1111-0000-0000-0000-000000000001" + + result = client._numbers.update( + uid, + incoming_call_action="webhook", + client_websocket_url="wss://agent.example.com/ws", + incoming_call_webhook_url="https://example.com/hook", + ) + + transport.patch.assert_called_once_with( + f"/numbers/{uid}", + json={ + "incoming_call_action": "webhook", + "client_websocket_url": "wss://agent.example.com/ws", + "incoming_call_webhook_url": "https://example.com/hook", + }, + ) + assert result.client_websocket_url == "wss://agent.example.com/ws" + assert result.incoming_call_webhook_url == "https://example.com/hook" + + def test_omitted_fields_not_sent(self, client, transport): + transport.patch.return_value = PHONE_NUMBER_DICT + uid = "aaaa1111-0000-0000-0000-000000000001" + + client._numbers.update(uid, incoming_call_action="auto_reject") + + _, kwargs = transport.patch.call_args + assert "client_websocket_url" not in kwargs["json"] + assert "incoming_call_webhook_url" not in kwargs["json"] + + +class TestNumbersProvision: + def test_provision_toll_free(self, client, transport): + transport.post.return_value = PHONE_NUMBER_DICT + + number = client._numbers.provision(agent_handle="sales-bot", type="toll_free") + + transport.post.assert_called_once_with( + "/numbers", + json={"agent_handle": "sales-bot", "type": "toll_free"}, + ) + assert number.type == "toll_free" + + def test_provision_local_with_state(self, client, transport): + local = {**PHONE_NUMBER_DICT, "type": "local", "number": "+12125551234"} + transport.post.return_value = local + + number = client._numbers.provision(agent_handle="sales-bot", type="local", state="NY") + + transport.post.assert_called_once_with( + "/numbers", + json={"agent_handle": "sales-bot", "type": "local", "state": "NY"}, + ) + assert number.type == "local" + + def test_provision_defaults_to_toll_free(self, client, transport): + transport.post.return_value = PHONE_NUMBER_DICT + + client._numbers.provision(agent_handle="sales-bot") + + _, kwargs = transport.post.call_args + assert kwargs["json"]["type"] == "toll_free" + assert kwargs["json"]["agent_handle"] == "sales-bot" + + +class TestNumbersRelease: + def test_release_deletes_by_id(self, client, transport): + uid = "aaaa1111-0000-0000-0000-000000000001" + + client._numbers.release(uid) + + transport.delete.assert_called_once_with(f"/numbers/{uid}") + + +class TestNumbersSearchTranscripts: + def test_search_with_query(self, client, transport): + transport.get.return_value = [PHONE_TRANSCRIPT_DICT] + uid = "aaaa1111-0000-0000-0000-000000000001" + + results = client._numbers.search_transcripts(uid, q="hello") + + transport.get.assert_called_once_with( + f"/numbers/{uid}/search", + params={"q": "hello", "party": None, "limit": 50}, + ) + assert len(results) == 1 + assert results[0].text == "Hello, how can I help you?" + + def test_search_with_party_and_limit(self, client, transport): + transport.get.return_value = [] + uid = "aaaa1111-0000-0000-0000-000000000001" + + results = client._numbers.search_transcripts( + uid, q="test", party="remote", limit=10 + ) + + transport.get.assert_called_once_with( + f"/numbers/{uid}/search", + params={"q": "test", "party": "remote", "limit": 10}, + ) + assert results == [] diff --git a/python/tests/test_signing_keys.py b/python/tests/test_signing_keys.py new file mode 100644 index 0000000..d8aba59 --- /dev/null +++ b/python/tests/test_signing_keys.py @@ -0,0 +1,102 @@ +"""Tests for SigningKeysResource and verify_webhook.""" + +import hashlib +import hmac +from datetime import datetime, timezone +from unittest.mock import MagicMock + +from inkbox.signing_keys import SigningKey, SigningKeysResource, verify_webhook + + +SIGNING_KEY_DICT = { + "signing_key": "sk-test-hmac-secret-abc123", + "created_at": "2026-03-09T00:00:00Z", +} + + +def _resource(): + http = MagicMock() + return SigningKeysResource(http), http + + +class TestSigningKeysCreateOrRotate: + def test_calls_correct_endpoint(self): + res, http = _resource() + http.post.return_value = SIGNING_KEY_DICT + + res.create_or_rotate() + + http.post.assert_called_once_with("/signing-keys", json={}) + + def test_returns_signing_key(self): + res, http = _resource() + http.post.return_value = SIGNING_KEY_DICT + + key = res.create_or_rotate() + + assert isinstance(key, SigningKey) + assert key.signing_key == "sk-test-hmac-secret-abc123" + assert key.created_at == datetime(2026, 3, 9, 0, 0, 0, tzinfo=timezone.utc) + + +def _make_signature(key: str, request_id: str, timestamp: str, body: bytes) -> str: + message = f"{request_id}.{timestamp}.".encode() + body + digest = hmac.new(key.encode(), message, hashlib.sha256).hexdigest() + return f"sha256={digest}" + + +class TestVerifyWebhook: + KEY = "test-signing-key" + REQUEST_ID = "req-abc-123" + TIMESTAMP = "1741737600" + BODY = b'{"event":"message.received"}' + + def _headers(self, sig: str) -> dict[str, str]: + return { + "X-Inkbox-Signature": sig, + "X-Inkbox-Request-ID": self.REQUEST_ID, + "X-Inkbox-Timestamp": self.TIMESTAMP, + } + + def test_valid_signature(self): + sig = _make_signature(self.KEY, self.REQUEST_ID, self.TIMESTAMP, self.BODY) + assert verify_webhook(payload=self.BODY, headers=self._headers(sig), secret=self.KEY) + + def test_valid_signature_with_whsec_prefix(self): + sig = _make_signature(self.KEY, self.REQUEST_ID, self.TIMESTAMP, self.BODY) + assert verify_webhook(payload=self.BODY, headers=self._headers(sig), secret=f"whsec_{self.KEY}") + + def test_headers_are_case_insensitive(self): + sig = _make_signature(self.KEY, self.REQUEST_ID, self.TIMESTAMP, self.BODY) + lowercase_headers = { + "x-inkbox-signature": sig, + "x-inkbox-request-id": self.REQUEST_ID, + "x-inkbox-timestamp": self.TIMESTAMP, + } + assert verify_webhook(payload=self.BODY, headers=lowercase_headers, secret=self.KEY) + + def test_wrong_key_returns_false(self): + sig = _make_signature("wrong-key", self.REQUEST_ID, self.TIMESTAMP, self.BODY) + assert not verify_webhook(payload=self.BODY, headers=self._headers(sig), secret=self.KEY) + + def test_tampered_body_returns_false(self): + sig = _make_signature(self.KEY, self.REQUEST_ID, self.TIMESTAMP, self.BODY) + assert not verify_webhook(payload=b'{"event":"message.sent"}', headers=self._headers(sig), secret=self.KEY) + + def test_wrong_request_id_returns_false(self): + sig = _make_signature(self.KEY, self.REQUEST_ID, self.TIMESTAMP, self.BODY) + headers = {**self._headers(sig), "X-Inkbox-Request-ID": "different-id"} + assert not verify_webhook(payload=self.BODY, headers=headers, secret=self.KEY) + + def test_wrong_timestamp_returns_false(self): + sig = _make_signature(self.KEY, self.REQUEST_ID, self.TIMESTAMP, self.BODY) + headers = {**self._headers(sig), "X-Inkbox-Timestamp": "9999999999"} + assert not verify_webhook(payload=self.BODY, headers=headers, secret=self.KEY) + + def test_missing_sha256_prefix_returns_false(self): + sig = _make_signature(self.KEY, self.REQUEST_ID, self.TIMESTAMP, self.BODY) + bare_hex = sig.removeprefix("sha256=") + assert not verify_webhook(payload=self.BODY, headers=self._headers(bare_hex), secret=self.KEY) + + def test_missing_headers_returns_false(self): + assert not verify_webhook(payload=self.BODY, headers={}, secret=self.KEY) diff --git a/python/tests/test_transcripts.py b/python/tests/test_transcripts.py new file mode 100644 index 0000000..fcccc40 --- /dev/null +++ b/python/tests/test_transcripts.py @@ -0,0 +1,43 @@ +"""Tests for TranscriptsResource.""" + +from uuid import UUID + +from sample_data import PHONE_TRANSCRIPT_DICT + + +NUM_ID = "aaaa1111-0000-0000-0000-000000000001" +CALL_ID = "bbbb2222-0000-0000-0000-000000000001" + + +class TestTranscriptsList: + def test_returns_transcripts(self, client, transport): + second = { + **PHONE_TRANSCRIPT_DICT, + "id": "cccc3333-0000-0000-0000-000000000002", + "seq": 1, + "ts_ms": 3000, + "party": "remote", + "text": "I need help with my account.", + } + transport.get.return_value = [PHONE_TRANSCRIPT_DICT, second] + + transcripts = client._transcripts.list(NUM_ID, CALL_ID) + + transport.get.assert_called_once_with( + f"/numbers/{NUM_ID}/calls/{CALL_ID}/transcripts", + ) + assert len(transcripts) == 2 + assert transcripts[0].seq == 0 + assert transcripts[0].party == "local" + assert transcripts[0].text == "Hello, how can I help you?" + assert transcripts[0].ts_ms == 1500 + assert transcripts[0].call_id == UUID(CALL_ID) + assert transcripts[1].seq == 1 + assert transcripts[1].party == "remote" + + def test_empty_transcripts(self, client, transport): + transport.get.return_value = [] + + transcripts = client._transcripts.list(NUM_ID, CALL_ID) + + assert transcripts == [] diff --git a/python/tests/test_types.py b/python/tests/test_types.py new file mode 100644 index 0000000..699a016 --- /dev/null +++ b/python/tests/test_types.py @@ -0,0 +1,67 @@ +"""Tests for type parsing.""" + +from datetime import datetime +from uuid import UUID + +from sample_data import ( + PHONE_NUMBER_DICT, + PHONE_CALL_DICT, + PHONE_TRANSCRIPT_DICT, +) +from inkbox.phone.types import ( + PhoneNumber, + PhoneCall, + PhoneTranscript, +) + + +class TestPhoneNumberParsing: + def test_from_dict(self): + n = PhoneNumber._from_dict(PHONE_NUMBER_DICT) + + assert isinstance(n.id, UUID) + assert n.number == "+18335794607" + assert n.type == "toll_free" + assert n.status == "active" + assert n.incoming_call_action == "auto_reject" + assert n.client_websocket_url is None + assert n.incoming_call_webhook_url is None + assert isinstance(n.created_at, datetime) + assert isinstance(n.updated_at, datetime) + + +class TestPhoneCallParsing: + def test_from_dict(self): + c = PhoneCall._from_dict(PHONE_CALL_DICT) + + assert isinstance(c.id, UUID) + assert c.local_phone_number == "+18335794607" + assert c.remote_phone_number == "+15167251294" + assert c.direction == "outbound" + assert c.status == "completed" + assert c.client_websocket_url == "wss://agent.example.com/ws" + assert c.use_inkbox_tts is None + assert c.use_inkbox_stt is None + assert isinstance(c.started_at, datetime) + assert isinstance(c.ended_at, datetime) + + def test_nullable_timestamps(self): + d = {**PHONE_CALL_DICT, "started_at": None, "ended_at": None} + + c = PhoneCall._from_dict(d) + + assert c.started_at is None + assert c.ended_at is None + + +class TestPhoneTranscriptParsing: + def test_from_dict(self): + t = PhoneTranscript._from_dict(PHONE_TRANSCRIPT_DICT) + + assert isinstance(t.id, UUID) + assert isinstance(t.call_id, UUID) + assert t.seq == 0 + assert t.ts_ms == 1500 + assert t.party == "local" + assert t.text == "Hello, how can I help you?" + assert isinstance(t.created_at, datetime) diff --git a/python/uv.lock b/python/uv.lock new file mode 100644 index 0000000..616a310 --- /dev/null +++ b/python/uv.lock @@ -0,0 +1,373 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, + { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, + { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" }, + { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" }, + { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" }, + { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" }, + { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" }, + { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" }, + { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, + { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, + { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, + { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, + { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, + { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, + { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, + { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, + { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, + { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, + { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, + { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, + { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, + { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, + { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, + { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "inkbox" +version = "0.1.0.2" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "python-dotenv" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" }, + { name = "python-dotenv", specifier = ">=1.2.2" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4" }, +] +provides-extras = ["dev"] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" }, + { url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" }, + { url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" }, + { url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" }, + { url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" }, + { url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" }, + { url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" }, + { url = "https://files.pythonhosted.org/packages/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676, upload-time = "2026-03-05T20:06:13.705Z" }, + { url = "https://files.pythonhosted.org/packages/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972, upload-time = "2026-03-05T20:06:45.379Z" }, + { url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] diff --git a/typescript/README.md b/typescript/README.md new file mode 100644 index 0000000..fd929b3 --- /dev/null +++ b/typescript/README.md @@ -0,0 +1,369 @@ +# @inkbox/sdk + +TypeScript SDK for the [Inkbox API](https://www.inkbox.ai/docs) — API-first communication infrastructure for AI agents (email, phone, identities). + +## Install + +```bash +npm install @inkbox/sdk +``` + +Requires Node.js ≥ 18. + +## Authentication + +You'll need an API key to use this SDK. Get one at [console.inkbox.ai](https://console.inkbox.ai/). + +## Quick start + +```ts +import { Inkbox } from "@inkbox/sdk"; + +const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); + +// Create an agent identity +const identity = await inkbox.createIdentity("support-bot"); + +// Create and link new channels +const mailbox = await identity.createMailbox({ displayName: "Support Bot" }); +const phone = await identity.provisionPhoneNumber({ type: "toll_free" }); + +// Send email directly from the identity +await identity.sendEmail({ + to: ["customer@example.com"], + subject: "Your order has shipped", + bodyText: "Tracking number: 1Z999AA10123456784", +}); + +// Place an outbound call +await identity.placeCall({ + toNumber: "+18005559999", + clientWebsocketUrl: "wss://my-app.com/voice", +}); + +// Read inbox +for await (const message of identity.iterEmails()) { + console.log(message.subject); +} + +// List calls +const calls = await identity.listCalls(); +``` + +## Authentication + +| Option | Type | Default | Description | +|---|---|---|---| +| `apiKey` | `string` | required | Your `ApiKey_...` token | +| `baseUrl` | `string` | API default | Override for self-hosting or testing | +| `timeoutMs` | `number` | `30000` | Request timeout in milliseconds | + +--- + +## Identities + +`inkbox.createIdentity()` and `inkbox.getIdentity()` return an `AgentIdentity` object that holds the identity's channels and exposes convenience methods scoped to those channels. + +```ts +// Create and fully provision an identity +const identity = await inkbox.createIdentity("sales-bot"); +const mailbox = await identity.createMailbox({ displayName: "Sales Bot" }); // creates + links +const phone = await identity.provisionPhoneNumber({ type: "toll_free" }); // provisions + links + +console.log(mailbox.emailAddress); +console.log(phone.number); + +// Link an existing mailbox or phone number instead of creating new ones +await identity.assignMailbox("mailbox-uuid-here"); +await identity.assignPhoneNumber("phone-number-uuid-here"); + +// Get an existing identity (returned with current channel state) +const identity2 = await inkbox.getIdentity("sales-bot"); +await identity2.refresh(); // re-fetch channels from API + +// List all identities for your org +const allIdentities = await inkbox.listIdentities(); + +// Update status or handle +await identity.update({ status: "paused" }); +await identity.update({ newHandle: "sales-bot-v2" }); + +// Unlink channels (without deleting them) +await identity.unlinkMailbox(); +await identity.unlinkPhoneNumber(); + +// Delete +await identity.delete(); +``` + +--- + +## Mail + +```ts +// Send an email (plain text and/or HTML) +const sent = await identity.sendEmail({ + to: ["user@example.com"], + subject: "Hello from Inkbox", + bodyText: "Hi there!", + bodyHtml: "

Hi there!

", + cc: ["manager@example.com"], + bcc: ["archive@example.com"], +}); + +// Send a threaded reply +await identity.sendEmail({ + to: ["user@example.com"], + subject: `Re: ${sent.subject}`, + bodyText: "Following up!", + inReplyToMessageId: sent.id, +}); + +// Send with attachments +await identity.sendEmail({ + to: ["user@example.com"], + subject: "See attached", + bodyText: "Please find the file attached.", + attachments: [{ + filename: "report.pdf", + contentType: "application/pdf", + contentBase64: "", + }], +}); + +// Iterate inbox (paginated automatically) +for await (const msg of identity.iterEmails()) { + console.log(msg.subject, msg.fromAddress, msg.isRead); +} + +// Filter by direction: "inbound" or "outbound" +for await (const msg of identity.iterEmails({ direction: "inbound" })) { + console.log(msg.subject); +} + +// Iterate only unread emails +for await (const msg of identity.iterUnreadEmails()) { + console.log(msg.subject); +} + +// Mark messages as read +const unread: string[] = []; +for await (const msg of identity.iterUnreadEmails()) unread.push(msg.id); +await identity.markEmailsRead(unread); + +// Get all emails in a thread (threadId comes from msg.threadId) +const thread = await identity.getThread(msg.threadId!); +for (const m of thread.messages) { + console.log(m.subject, m.fromAddress); +} +``` + +--- + +## Phone + +```ts +// Place an outbound call — stream audio over WebSocket +const call = await identity.placeCall({ + toNumber: "+15167251294", + clientWebsocketUrl: "wss://your-agent.example.com/ws", +}); +console.log(call.status, call.rateLimit.callsRemaining); + +// Or receive call events via webhook instead +const call2 = await identity.placeCall({ + toNumber: "+15167251294", + webhookUrl: "https://your-agent.example.com/call-events", +}); + +// List calls (paginated) +const calls = await identity.listCalls({ limit: 10, offset: 0 }); +for (const c of calls) { + console.log(c.id, c.direction, c.remotePhoneNumber, c.status); +} + +// Fetch transcript segments for a call +const segments = await identity.listTranscripts(calls[0].id); +for (const t of segments) { + console.log(`[${t.party}] ${t.text}`); // party: "local" or "remote" +} + +// Read transcripts across all recent calls +const recentCalls = await identity.listCalls({ limit: 10 }); +for (const call of recentCalls) { + const segs = await identity.listTranscripts(call.id); + if (!segs.length) continue; + console.log(`\n--- Call ${call.id} (${call.direction}) ---`); + for (const t of segs) { + console.log(` [${t.party.padEnd(6)}] ${t.text}`); + } +} + +// Filter to only the remote party's speech +const remoteOnly = segments.filter(t => t.party === "remote"); +for (const t of remoteOnly) console.log(t.text); + +// Search transcripts across a phone number (org-level) +const hits = await inkbox.phoneNumbers.searchTranscripts(phone.id, { q: "refund", party: "remote" }); +for (const t of hits) { + console.log(`[${t.party}] ${t.text}`); +} +``` + +--- + +## Org-level Mailboxes + +Manage mailboxes directly without going through an identity. Access via `inkbox.mailboxes`. + +```ts +// List all mailboxes in the organisation +const mailboxes = await inkbox.mailboxes.list(); + +// Get a specific mailbox +const mailbox = await inkbox.mailboxes.get("abc-xyz@inkboxmail.com"); + +// Create a standalone mailbox +const mb = await inkbox.mailboxes.create({ displayName: "Support Inbox" }); +console.log(mb.emailAddress); + +// Update display name or webhook URL +await inkbox.mailboxes.update(mb.emailAddress, { displayName: "New Name" }); +await inkbox.mailboxes.update(mb.emailAddress, { webhookUrl: "https://example.com/hook" }); +await inkbox.mailboxes.update(mb.emailAddress, { webhookUrl: null }); // remove webhook + +// Full-text search across messages in a mailbox +const results = await inkbox.mailboxes.search(mb.emailAddress, { q: "invoice", limit: 20 }); +for (const msg of results) { + console.log(msg.subject, msg.fromAddress); +} + +// Delete a mailbox +await inkbox.mailboxes.delete(mb.emailAddress); +``` + +--- + +## Org-level Phone Numbers + +Manage phone numbers directly without going through an identity. Access via `inkbox.phoneNumbers`. + +```ts +// List all phone numbers in the organisation +const numbers = await inkbox.phoneNumbers.list(); + +// Get a specific phone number by ID +const number = await inkbox.phoneNumbers.get("phone-number-uuid"); + +// Provision a new number +const num = await inkbox.phoneNumbers.provision({ type: "toll_free" }); +const local = await inkbox.phoneNumbers.provision({ type: "local", state: "NY" }); + +// Update incoming call behaviour +await inkbox.phoneNumbers.update(num.id, { + incomingCallAction: "webhook", + incomingCallWebhookUrl: "https://example.com/calls", +}); +await inkbox.phoneNumbers.update(num.id, { + incomingCallAction: "auto_accept", + clientWebsocketUrl: "wss://example.com/ws", +}); + +// Full-text search across transcripts +const hits = await inkbox.phoneNumbers.searchTranscripts(num.id, { q: "refund", party: "remote" }); +for (const t of hits) { + console.log(`[${t.party}] ${t.text}`); +} + +// Release a number +await inkbox.phoneNumbers.release({ number: num.number }); +``` + +--- + +## Webhooks + +Webhooks are configured on the mailbox or phone number resource — no separate registration step. + +### Mailbox webhooks + +Set a URL on a mailbox to receive `message.received` and `message.sent` events. + +```ts +// Set webhook +await inkbox.mailboxes.update("abc@inkboxmail.com", { webhookUrl: "https://example.com/hook" }); + +// Remove webhook +await inkbox.mailboxes.update("abc@inkboxmail.com", { webhookUrl: null }); +``` + +### Phone webhooks + +Set an incoming call webhook URL and action on a phone number. + +```ts +// Route incoming calls to a webhook +await inkbox.phoneNumbers.update(number.id, { + incomingCallAction: "webhook", + incomingCallWebhookUrl: "https://example.com/calls", +}); +``` + +You can also supply a per-call webhook URL when placing a call: + +```ts +await identity.placeCall({ toNumber: "+15005550006", webhookUrl: "https://example.com/call-events" }); +``` + +--- + +## Signing Keys + +```ts +// Create or rotate the org-level webhook signing key (plaintext returned once) +const key = await inkbox.createSigningKey(); +console.log(key.signingKey); // save this immediately +``` + +--- + +## Verifying Webhook Signatures + +Use `verifyWebhook` to confirm that an incoming request was sent by Inkbox. + +```typescript +import { verifyWebhook } from "@inkbox/sdk"; + +// Express — use express.raw() to get the raw body Buffer +app.post("/hooks/mail", express.raw({ type: "*/*" }), (req, res) => { + const valid = verifyWebhook({ + payload: req.body, + headers: req.headers, + secret: "whsec_...", + }); + if (!valid) return res.status(403).end(); + // handle event ... +}); +``` + +--- + +## Examples + +Runnable example scripts are available in the [examples/typescript](https://github.com/vectorlyapp/inkbox/tree/main/inkbox/examples/typescript) directory: + +| Script | What it demonstrates | +|---|---| +| `register-agent-identity.ts` | Create an identity, assign mailbox + phone number | +| `agent-send-email.ts` | Send an email and a threaded reply | +| `read-agent-messages.ts` | List messages and threads | +| `create-agent-mailbox.ts` | Create, update, search, and delete a mailbox | +| `create-agent-phone-number.ts` | Provision, update, and release a number | +| `list-agent-phone-numbers.ts` | List all phone numbers in the org | +| `read-agent-calls.ts` | List calls and print transcripts | +| `receive-agent-email-webhook.ts` | Register and delete a mailbox webhook | +| `receive-agent-call-webhook.ts` | Register, update, and delete a phone webhook | + +## License + +MIT diff --git a/typescript/package.json b/typescript/package.json index 1f8e93c..eba371a 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -12,13 +12,20 @@ "types": "./dist/index.d.ts" } }, - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "build": "tsc", + "test": "vitest run", + "test:coverage": "vitest run --coverage", "prepublishOnly": "npm run build" }, "devDependencies": { - "typescript": "^5.4.0" + "@types/node": "^25.5.0", + "@vitest/coverage-v8": "^2.0.0", + "typescript": "^5.4.0", + "vitest": "^2.0.0" }, "repository": { "type": "git", diff --git a/typescript/src/agent_identity.ts b/typescript/src/agent_identity.ts new file mode 100644 index 0000000..4635712 --- /dev/null +++ b/typescript/src/agent_identity.ts @@ -0,0 +1,321 @@ +/** + * inkbox/src/agent.ts + * + * AgentIdentity — a domain object representing one agent identity. + * Returned by inkbox.createIdentity() and inkbox.getIdentity(). + * + * Convenience methods (sendEmail, placeCall, etc.) are scoped to this + * identity's assigned channels so callers never need to pass an email + * address or phone number ID explicitly. + */ + +import { InkboxAPIError } from "./_http.js"; +import type { Message, ThreadDetail } from "./mail/types.js"; +import type { PhoneCall, PhoneCallWithRateLimit, PhoneTranscript } from "./phone/types.js"; +import type { + AgentIdentitySummary, + _AgentIdentityData, + IdentityMailbox, + IdentityPhoneNumber, +} from "./identities/types.js"; +import type { Inkbox } from "./inkbox.js"; + +export class AgentIdentity { + private _data: _AgentIdentityData; + private readonly _inkbox: Inkbox; + private _mailbox: IdentityMailbox | null; + private _phoneNumber: IdentityPhoneNumber | null; + + constructor(data: _AgentIdentityData, inkbox: Inkbox) { + this._data = data; + this._inkbox = inkbox; + this._mailbox = data.mailbox; + this._phoneNumber = data.phoneNumber; + } + + // ------------------------------------------------------------------ + // Identity properties + // ------------------------------------------------------------------ + + get agentHandle(): string { return this._data.agentHandle; } + get id(): string { return this._data.id; } + get status(): string { return this._data.status; } + + /** The mailbox currently assigned to this identity, or `null` if none. */ + get mailbox(): IdentityMailbox | null { return this._mailbox; } + + /** The phone number currently assigned to this identity, or `null` if none. */ + get phoneNumber(): IdentityPhoneNumber | null { return this._phoneNumber; } + + // ------------------------------------------------------------------ + // Channel management + // ------------------------------------------------------------------ + + /** + * Create a new mailbox and link it to this identity. + * + * @param options.displayName - Optional human-readable sender name. + * @returns The newly created and linked {@link IdentityMailbox}. + */ + async createMailbox(options: { displayName?: string } = {}): Promise { + const mailbox = await this._inkbox._mailboxes.create(options); + const data = await this._inkbox._idsResource.assignMailbox(this.agentHandle, { + mailboxId: mailbox.id, + }); + this._mailbox = data.mailbox; + this._data = data; + return this._mailbox!; + } + + /** + * Link an existing mailbox to this identity. + * + * @param mailboxId - UUID of the mailbox to link. Obtain via + * `inkbox.mailboxes.list()` or `inkbox.mailboxes.get()`. + * @returns The linked {@link IdentityMailbox}. + */ + async assignMailbox(mailboxId: string): Promise { + const data = await this._inkbox._idsResource.assignMailbox(this.agentHandle, { + mailboxId, + }); + this._mailbox = data.mailbox; + this._data = data; + return this._mailbox!; + } + + /** + * Unlink this identity's mailbox (does not delete the mailbox). + */ + async unlinkMailbox(): Promise { + this._requireMailbox(); + await this._inkbox._idsResource.unlinkMailbox(this.agentHandle); + this._mailbox = null; + } + + /** + * Provision a new phone number and link it to this identity. + * + * @param options.type - `"toll_free"` (default) or `"local"`. + * @param options.state - US state abbreviation (e.g. `"NY"`), valid for local numbers only. + * @returns The newly provisioned and linked {@link IdentityPhoneNumber}. + */ + async provisionPhoneNumber( + options: { type?: string; state?: string } = {}, + ): Promise { + await this._inkbox._numbers.provision({ agentHandle: this.agentHandle, ...options }); + const data = await this._inkbox._idsResource.get(this.agentHandle); + this._phoneNumber = data.phoneNumber; + this._data = data; + return this._phoneNumber!; + } + + /** + * Link an existing phone number to this identity. + * + * @param phoneNumberId - UUID of the phone number to link. Obtain via + * `inkbox.phoneNumbers.list()` or `inkbox.phoneNumbers.get()`. + * @returns The linked {@link IdentityPhoneNumber}. + */ + async assignPhoneNumber(phoneNumberId: string): Promise { + const data = await this._inkbox._idsResource.assignPhoneNumber(this.agentHandle, { + phoneNumberId, + }); + this._phoneNumber = data.phoneNumber; + this._data = data; + return this._phoneNumber!; + } + + /** + * Unlink this identity's phone number (does not release the number). + */ + async unlinkPhoneNumber(): Promise { + this._requirePhone(); + await this._inkbox._idsResource.unlinkPhoneNumber(this.agentHandle); + this._phoneNumber = null; + } + + // ------------------------------------------------------------------ + // Mail helpers + // ------------------------------------------------------------------ + + /** + * Send an email from this identity's mailbox. + * + * @param options.to - Primary recipient addresses (at least one required). + * @param options.subject - Email subject line. + * @param options.bodyText - Plain-text body. + * @param options.bodyHtml - HTML body. + * @param options.cc - Carbon-copy recipients. + * @param options.bcc - Blind carbon-copy recipients. + * @param options.inReplyToMessageId - RFC 5322 Message-ID to thread a reply. + * @param options.attachments - File attachments. + */ + async sendEmail(options: { + to: string[]; + subject: string; + bodyText?: string; + bodyHtml?: string; + cc?: string[]; + bcc?: string[]; + inReplyToMessageId?: string; + attachments?: Array<{ filename: string; contentType: string; contentBase64: string }>; + }): Promise { + this._requireMailbox(); + return this._inkbox._messages.send(this._mailbox!.emailAddress, options); + } + + /** + * Iterate over emails in this identity's inbox, newest first. + * + * Pagination is handled automatically. + * + * @param options.pageSize - Messages fetched per API call (1–100). Defaults to 50. + * @param options.direction - Filter by `"inbound"` or `"outbound"`. + */ + iterEmails(options: { pageSize?: number; direction?: "inbound" | "outbound" } = {}): AsyncGenerator { + this._requireMailbox(); + return this._inkbox._messages.list(this._mailbox!.emailAddress, options); + } + + /** + * Iterate over unread emails in this identity's inbox, newest first. + * + * Fetches all messages and filters client-side. Pagination is handled automatically. + * + * @param options.pageSize - Messages fetched per API call (1–100). Defaults to 50. + * @param options.direction - Filter by `"inbound"` or `"outbound"`. + */ + async *iterUnreadEmails(options: { pageSize?: number; direction?: "inbound" | "outbound" } = {}): AsyncGenerator { + for await (const msg of this.iterEmails(options)) { + if (!msg.isRead) yield msg; + } + } + + /** + * Mark a list of messages as read. + * + * @param messageIds - IDs of the messages to mark as read. + */ + async markEmailsRead(messageIds: string[]): Promise { + this._requireMailbox(); + for (const id of messageIds) { + await this._inkbox._messages.markRead(this._mailbox!.emailAddress, id); + } + } + + /** + * Get a thread with all its messages inlined (oldest-first). + * + * @param threadId - UUID of the thread to fetch. Obtain via `msg.threadId` + * on any {@link Message}. + */ + async getThread(threadId: string): Promise { + this._requireMailbox(); + return this._inkbox._threads.get(this._mailbox!.emailAddress, threadId); + } + + // ------------------------------------------------------------------ + // Phone helpers + // ------------------------------------------------------------------ + + /** + * Place an outbound call from this identity's phone number. + * + * @param options.toNumber - E.164 destination number. + * @param options.clientWebsocketUrl - WebSocket URL (wss://) for audio bridging. + * @param options.webhookUrl - Custom webhook URL for call lifecycle events. + */ + async placeCall(options: { + toNumber: string; + clientWebsocketUrl?: string; + webhookUrl?: string; + }): Promise { + this._requirePhone(); + return this._inkbox._calls.place({ + fromNumber: this._phoneNumber!.number, + toNumber: options.toNumber, + clientWebsocketUrl: options.clientWebsocketUrl, + webhookUrl: options.webhookUrl, + }); + } + + /** + * List calls made to/from this identity's phone number. + * + * @param options.limit - Maximum number of results. Defaults to 50. + * @param options.offset - Pagination offset. Defaults to 0. + */ + async listCalls(options: { limit?: number; offset?: number } = {}): Promise { + this._requirePhone(); + return this._inkbox._calls.list(this._phoneNumber!.id, options); + } + + /** + * List transcript segments for a specific call. + * + * @param callId - ID of the call to fetch transcripts for. + */ + async listTranscripts(callId: string): Promise { + this._requirePhone(); + return this._inkbox._transcripts.list(this._phoneNumber!.id, callId); + } + + // ------------------------------------------------------------------ + // Identity management + // ------------------------------------------------------------------ + + /** + * Update this identity's handle or status. + * + * @param options.newHandle - New agent handle. + * @param options.status - New lifecycle status: `"active"` or `"paused"`. + */ + async update(options: { newHandle?: string; status?: string }): Promise { + const result = await this._inkbox._idsResource.update(this.agentHandle, options); + this._data = { + ...result, + mailbox: this._mailbox, + phoneNumber: this._phoneNumber, + }; + } + + /** + * Re-fetch this identity from the API and update cached channels. + * + * @returns `this` for chaining. + */ + async refresh(): Promise { + const data = await this._inkbox._idsResource.get(this.agentHandle); + this._data = data; + this._mailbox = data.mailbox; + this._phoneNumber = data.phoneNumber; + return this; + } + + /** Soft-delete this identity (unlinks channels without deleting them). */ + async delete(): Promise { + await this._inkbox._idsResource.delete(this.agentHandle); + } + + // ------------------------------------------------------------------ + // Internal guards + // ------------------------------------------------------------------ + + private _requireMailbox(): void { + if (!this._mailbox) { + throw new InkboxAPIError( + 0, + `Identity '${this.agentHandle}' has no mailbox assigned. Call identity.createMailbox() or identity.assignMailbox() first.`, + ); + } + } + + private _requirePhone(): void { + if (!this._phoneNumber) { + throw new InkboxAPIError( + 0, + `Identity '${this.agentHandle}' has no phone number assigned. Call identity.provisionPhoneNumber() or identity.assignPhoneNumber() first.`, + ); + } + } +} diff --git a/typescript/src/client.ts b/typescript/src/client.ts deleted file mode 100644 index 91bb805..0000000 --- a/typescript/src/client.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * inkbox-mail/client.ts - * - * Top-level InkboxMail client. - */ - -import { HttpTransport } from "./_http.js"; -import { MailboxesResource } from "./resources/mailboxes.js"; -import { MessagesResource } from "./resources/messages.js"; -import { ThreadsResource } from "./resources/threads.js"; -import { WebhooksResource } from "./resources/webhooks.js"; - -const DEFAULT_BASE_URL = "https://api.inkbox.ai/api/v1/mail"; - -export interface InkboxMailOptions { - /** Your Inkbox API key (sent as `X-Service-Token`). */ - apiKey: string; - /** Override the API base URL (useful for self-hosting or testing). */ - baseUrl?: string; - /** Request timeout in milliseconds. Defaults to 30 000. */ - timeoutMs?: number; -} - -/** - * Async client for the Inkbox Mail API. - * - * @example - * ```ts - * import { InkboxMail } from "@inkbox/mail"; - * - * const client = new InkboxMail({ apiKey: "sk-..." }); - * - * const mailbox = await client.mailboxes.create({ displayName: "Agent 01" }); - * - * await client.messages.send(mailbox.id, { - * to: ["user@example.com"], - * subject: "Hello from Inkbox", - * bodyText: "Hi there!", - * }); - * - * for await (const msg of client.messages.list(mailbox.id)) { - * console.log(msg.subject, msg.fromAddress); - * } - * ``` - */ -export class InkboxMail { - readonly mailboxes: MailboxesResource; - readonly messages: MessagesResource; - readonly threads: ThreadsResource; - readonly webhooks: WebhooksResource; - - private readonly http: HttpTransport; - - constructor(options: InkboxMailOptions) { - this.http = new HttpTransport( - options.apiKey, - options.baseUrl ?? DEFAULT_BASE_URL, - options.timeoutMs ?? 30_000, - ); - this.mailboxes = new MailboxesResource(this.http); - this.messages = new MessagesResource(this.http); - this.threads = new ThreadsResource(this.http); - this.webhooks = new WebhooksResource(this.http); - } -} diff --git a/typescript/src/identities/resources/identities.ts b/typescript/src/identities/resources/identities.ts new file mode 100644 index 0000000..3b38507 --- /dev/null +++ b/typescript/src/identities/resources/identities.ts @@ -0,0 +1,131 @@ +/** + * inkbox-identities/resources/identities.ts + * + * Identity CRUD and channel assignment. + */ + +import { HttpTransport } from "../../_http.js"; +import { + AgentIdentitySummary, + _AgentIdentityData, + RawAgentIdentitySummary, + RawAgentIdentityData, + parseAgentIdentitySummary, + parseAgentIdentityData, +} from "../types.js"; + +export class IdentitiesResource { + constructor(private readonly http: HttpTransport) {} + + /** + * Create a new agent identity. + * + * @param options.agentHandle - Unique handle for this identity within your organisation + * (e.g. `"sales-agent"` or `"@sales-agent"`). + */ + async create(options: { agentHandle: string }): Promise { + const data = await this.http.post("/", { + agent_handle: options.agentHandle, + }); + return parseAgentIdentitySummary(data); + } + + /** List all identities for your organisation. */ + async list(): Promise { + const data = await this.http.get("/"); + return data.map(parseAgentIdentitySummary); + } + + /** + * Get an identity with its linked channels (mailbox, phone number). + * + * @param agentHandle - Handle of the identity to fetch. + */ + async get(agentHandle: string): Promise<_AgentIdentityData> { + const data = await this.http.get(`/${agentHandle}`); + return parseAgentIdentityData(data); + } + + /** + * Update an identity's handle or status. + * + * Only provided fields are applied; omitted fields are left unchanged. + * + * @param agentHandle - Current handle of the identity to update. + * @param options.newHandle - New handle value. + * @param options.status - New lifecycle status: `"active"` or `"paused"`. + */ + async update( + agentHandle: string, + options: { newHandle?: string; status?: string }, + ): Promise { + const body: Record = {}; + if (options.newHandle !== undefined) body["agent_handle"] = options.newHandle; + if (options.status !== undefined) body["status"] = options.status; + const data = await this.http.patch(`/${agentHandle}`, body); + return parseAgentIdentitySummary(data); + } + + /** + * Soft-delete an identity. + * + * Unlinks any assigned channels without deleting them. + * + * @param agentHandle - Handle of the identity to delete. + */ + async delete(agentHandle: string): Promise { + await this.http.delete(`/${agentHandle}`); + } + + /** + * Assign a mailbox to an identity. + * + * @param agentHandle - Handle of the identity. + * @param options.mailboxId - UUID of the mailbox to assign. + */ + async assignMailbox( + agentHandle: string, + options: { mailboxId: string }, + ): Promise<_AgentIdentityData> { + const data = await this.http.post( + `/${agentHandle}/mailbox`, + { mailbox_id: options.mailboxId }, + ); + return parseAgentIdentityData(data); + } + + /** + * Unlink the mailbox from an identity (does not delete the mailbox). + * + * @param agentHandle - Handle of the identity. + */ + async unlinkMailbox(agentHandle: string): Promise { + await this.http.delete(`/${agentHandle}/mailbox`); + } + + /** + * Assign a phone number to an identity. + * + * @param agentHandle - Handle of the identity. + * @param options.phoneNumberId - UUID of the phone number to assign. + */ + async assignPhoneNumber( + agentHandle: string, + options: { phoneNumberId: string }, + ): Promise<_AgentIdentityData> { + const data = await this.http.post( + `/${agentHandle}/phone_number`, + { phone_number_id: options.phoneNumberId }, + ); + return parseAgentIdentityData(data); + } + + /** + * Unlink the phone number from an identity (does not delete the number). + * + * @param agentHandle - Handle of the identity. + */ + async unlinkPhoneNumber(agentHandle: string): Promise { + await this.http.delete(`/${agentHandle}/phone_number`); + } +} diff --git a/typescript/src/identities/types.ts b/typescript/src/identities/types.ts new file mode 100644 index 0000000..400e4b7 --- /dev/null +++ b/typescript/src/identities/types.ts @@ -0,0 +1,127 @@ +/** + * inkbox-identities TypeScript SDK — public types. + */ + +export interface IdentityMailbox { + id: string; + emailAddress: string; + displayName: string | null; + /** "active" | "paused" | "deleted" */ + status: string; + createdAt: Date; + updatedAt: Date; +} + +export interface IdentityPhoneNumber { + id: string; + number: string; + /** "toll_free" | "local" */ + type: string; + /** "active" | "paused" | "released" */ + status: string; + /** "auto_accept" | "auto_reject" | "webhook" */ + incomingCallAction: string; + clientWebsocketUrl: string | null; + createdAt: Date; + updatedAt: Date; +} + +/** Lightweight identity returned by list and update endpoints. */ +export interface AgentIdentitySummary { + id: string; + organizationId: string; + agentHandle: string; + /** "active" | "paused" | "deleted" */ + status: string; + createdAt: Date; + updatedAt: Date; +} + +/** @internal Full identity data with channels — users interact with AgentIdentity (the class) instead. */ +export interface _AgentIdentityData extends AgentIdentitySummary { + /** Mailbox assigned to this identity, or null if unlinked. */ + mailbox: IdentityMailbox | null; + /** Phone number assigned to this identity, or null if unlinked. */ + phoneNumber: IdentityPhoneNumber | null; +} + +// ---- internal raw API shapes (snake_case from JSON) ---- + +export interface RawIdentityMailbox { + id: string; + email_address: string; + display_name: string | null; + status: string; + created_at: string; + updated_at: string; +} + +export interface RawIdentityPhoneNumber { + id: string; + number: string; + type: string; + status: string; + incoming_call_action: string; + client_websocket_url: string | null; + created_at: string; + updated_at: string; +} + +export interface RawAgentIdentitySummary { + id: string; + organization_id: string; + agent_handle: string; + status: string; + created_at: string; + updated_at: string; +} + +export interface RawAgentIdentityData extends RawAgentIdentitySummary { + mailbox: RawIdentityMailbox | null; + phone_number: RawIdentityPhoneNumber | null; +} + +// ---- parsers ---- + +export function parseIdentityMailbox(r: RawIdentityMailbox): IdentityMailbox { + return { + id: r.id, + emailAddress: r.email_address, + displayName: r.display_name, + status: r.status, + createdAt: new Date(r.created_at), + updatedAt: new Date(r.updated_at), + }; +} + +export function parseIdentityPhoneNumber(r: RawIdentityPhoneNumber): IdentityPhoneNumber { + return { + id: r.id, + number: r.number, + type: r.type, + status: r.status, + incomingCallAction: r.incoming_call_action, + clientWebsocketUrl: r.client_websocket_url, + createdAt: new Date(r.created_at), + updatedAt: new Date(r.updated_at), + }; +} + +export function parseAgentIdentitySummary(r: RawAgentIdentitySummary): AgentIdentitySummary { + return { + id: r.id, + organizationId: r.organization_id, + agentHandle: r.agent_handle, + status: r.status, + createdAt: new Date(r.created_at), + updatedAt: new Date(r.updated_at), + }; +} + +export function parseAgentIdentityData(r: RawAgentIdentityData): _AgentIdentityData { + return { + ...parseAgentIdentitySummary(r), + mailbox: r.mailbox ? parseIdentityMailbox(r.mailbox) : null, + phoneNumber: r.phone_number ? parseIdentityPhoneNumber(r.phone_number) : null, + }; +} diff --git a/typescript/src/index.ts b/typescript/src/index.ts index 3f4443f..b1e927e 100644 --- a/typescript/src/index.ts +++ b/typescript/src/index.ts @@ -1,11 +1,25 @@ -export { InkboxMail } from "./client.js"; +export { Inkbox } from "./inkbox.js"; +export { AgentIdentity } from "./agent_identity.js"; +export type { InkboxOptions } from "./inkbox.js"; export { InkboxAPIError } from "./_http.js"; +export type { SigningKey } from "./signing_keys.js"; +export { verifyWebhook } from "./signing_keys.js"; export type { Mailbox, Message, MessageDetail, Thread, ThreadDetail, - Webhook, - WebhookCreateResult, -} from "./types.js"; +} from "./mail/types.js"; +export type { + PhoneNumber, + PhoneCall, + PhoneCallWithRateLimit, + RateLimitInfo, + PhoneTranscript, +} from "./phone/types.js"; +export type { + AgentIdentitySummary, + IdentityMailbox, + IdentityPhoneNumber, +} from "./identities/types.js"; diff --git a/typescript/src/inkbox.ts b/typescript/src/inkbox.ts new file mode 100644 index 0000000..069d337 --- /dev/null +++ b/typescript/src/inkbox.ts @@ -0,0 +1,143 @@ +/** + * inkbox/src/inkbox.ts + * + * Inkbox — org-level entry point for all Inkbox APIs. + */ + +import { HttpTransport } from "./_http.js"; +import { MailboxesResource } from "./mail/resources/mailboxes.js"; +import { MessagesResource } from "./mail/resources/messages.js"; +import { ThreadsResource } from "./mail/resources/threads.js"; +import { SigningKeysResource } from "./signing_keys.js"; +import type { SigningKey } from "./signing_keys.js"; +import { PhoneNumbersResource } from "./phone/resources/numbers.js"; +import { CallsResource } from "./phone/resources/calls.js"; +import { TranscriptsResource } from "./phone/resources/transcripts.js"; +import { IdentitiesResource } from "./identities/resources/identities.js"; +import { AgentIdentity } from "./agent_identity.js"; +import type { AgentIdentitySummary } from "./identities/types.js"; + +const DEFAULT_BASE_URL = "https://api.inkbox.ai"; + +export interface InkboxOptions { + /** Your Inkbox API key (sent as `X-Service-Token`). */ + apiKey: string; + /** Override the API base URL (useful for self-hosting or testing). */ + baseUrl?: string; + /** Request timeout in milliseconds. Defaults to 30 000. */ + timeoutMs?: number; +} + +/** + * Org-level entry point for all Inkbox APIs. + * + * @example + * ```ts + * import { Inkbox } from "@inkbox/sdk"; + * + * const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! }); + * + * // Create an agent identity + * const identity = await inkbox.createIdentity("support-bot"); + * + * // Create and link new channels + * const mailbox = await identity.createMailbox({ displayName: "Support Bot" }); + * const phone = await identity.provisionPhoneNumber({ type: "toll_free" }); + * + * // Send email directly from the identity + * await identity.sendEmail({ + * to: ["customer@example.com"], + * subject: "Your order has shipped", + * bodyText: "Tracking number: 1Z999AA10123456784", + * }); + * ``` + */ +export class Inkbox { + readonly _mailboxes: MailboxesResource; + readonly _messages: MessagesResource; + readonly _threads: ThreadsResource; + readonly _signingKeys: SigningKeysResource; + readonly _numbers: PhoneNumbersResource; + readonly _calls: CallsResource; + readonly _transcripts: TranscriptsResource; + readonly _idsResource: IdentitiesResource; + + constructor(options: InkboxOptions) { + const apiRoot = `${(options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "")}/api/v1`; + const ms = options.timeoutMs ?? 30_000; + + const mailHttp = new HttpTransport(options.apiKey, `${apiRoot}/mail`, ms); + const phoneHttp = new HttpTransport(options.apiKey, `${apiRoot}/phone`, ms); + const idsHttp = new HttpTransport(options.apiKey, `${apiRoot}/identities`, ms); + const apiHttp = new HttpTransport(options.apiKey, apiRoot, ms); + + this._mailboxes = new MailboxesResource(mailHttp); + this._messages = new MessagesResource(mailHttp); + this._threads = new ThreadsResource(mailHttp); + this._signingKeys = new SigningKeysResource(apiHttp); + + this._numbers = new PhoneNumbersResource(phoneHttp); + this._calls = new CallsResource(phoneHttp); + this._transcripts = new TranscriptsResource(phoneHttp); + + this._idsResource = new IdentitiesResource(idsHttp); + } + + // ------------------------------------------------------------------ + // Public resource accessors + // ------------------------------------------------------------------ + + /** Org-level mailbox operations (list, get, create, update, delete). */ + get mailboxes(): MailboxesResource { return this._mailboxes; } + + /** Org-level phone number operations (list, get, provision, release). */ + get phoneNumbers(): PhoneNumbersResource { return this._numbers; } + + // ------------------------------------------------------------------ + // Org-level operations + // ------------------------------------------------------------------ + + /** + * Create a new agent identity. + * + * @param agentHandle - Unique handle for this identity (e.g. `"sales-bot"`). + * @returns The created {@link AgentIdentity}. + */ + async createIdentity(agentHandle: string): Promise { + await this._idsResource.create({ agentHandle }); + // POST /identities returns summary (no channel fields); fetch detail so + // AgentIdentity has a fully-populated _AgentIdentityData. + const data = await this._idsResource.get(agentHandle); + return new AgentIdentity(data, this); + } + + /** + * Get an existing agent identity by handle. + * + * @param agentHandle - Handle of the identity to fetch. + * @returns The {@link AgentIdentity}. + */ + async getIdentity(agentHandle: string): Promise { + return new AgentIdentity(await this._idsResource.get(agentHandle), this); + } + + /** + * List all agent identities for your organisation. + * + * @returns Array of {@link AgentIdentitySummary}. + */ + async listIdentities(): Promise { + return this._idsResource.list(); + } + + /** + * Create or rotate the org-level webhook signing key. + * + * The plaintext key is returned once — save it immediately. + * + * @returns The new {@link SigningKey}. + */ + async createSigningKey(): Promise { + return this._signingKeys.createOrRotate(); + } +} diff --git a/typescript/src/mail/resources/mailboxes.ts b/typescript/src/mail/resources/mailboxes.ts new file mode 100644 index 0000000..f7ea312 --- /dev/null +++ b/typescript/src/mail/resources/mailboxes.ts @@ -0,0 +1,106 @@ +/** + * inkbox-mail/resources/mailboxes.ts + * + * Mailbox CRUD and full-text search. + */ + +import { HttpTransport } from "../../_http.js"; +import { + Mailbox, + Message, + RawCursorPage, + RawMailbox, + RawMessage, + parseMailbox, + parseMessage, +} from "../types.js"; + +const BASE = "/mailboxes"; + +export class MailboxesResource { + constructor(private readonly http: HttpTransport) {} + + /** + * Create a new mailbox. + * + * The email address is automatically generated by the server. + * + * @param options.displayName - Optional human-readable name shown as the sender. + */ + async create(options: { displayName?: string } = {}): Promise { + const body: Record = {}; + if (options.displayName !== undefined) { + body["display_name"] = options.displayName; + } + const data = await this.http.post(BASE, body); + return parseMailbox(data); + } + + /** List all mailboxes for your organisation. */ + async list(): Promise { + const data = await this.http.get(BASE); + return data.map(parseMailbox); + } + + /** + * Get a mailbox by its email address. + * + * @param emailAddress - Full email address of the mailbox (e.g. `"abc-xyz@inkboxmail.com"`). + */ + async get(emailAddress: string): Promise { + const data = await this.http.get(`${BASE}/${emailAddress}`); + return parseMailbox(data); + } + + /** + * Update mutable mailbox fields. + * + * Only provided fields are applied; omitted fields are left unchanged. + * Pass `webhookUrl: null` to unsubscribe from webhooks. + * + * @param emailAddress - Full email address of the mailbox to update. + * @param options.displayName - New human-readable sender name. + * @param options.webhookUrl - HTTPS URL to receive webhook events, or `null` to unsubscribe. + */ + async update( + emailAddress: string, + options: { displayName?: string; webhookUrl?: string | null }, + ): Promise { + const body: Record = {}; + if (options.displayName !== undefined) { + body["display_name"] = options.displayName; + } + if ("webhookUrl" in options) { + body["webhook_url"] = options.webhookUrl; + } + const data = await this.http.patch(`${BASE}/${emailAddress}`, body); + return parseMailbox(data); + } + + /** + * Delete a mailbox. + * + * @param emailAddress - Full email address of the mailbox to delete. + */ + async delete(emailAddress: string): Promise { + await this.http.delete(`${BASE}/${emailAddress}`); + } + + /** + * Full-text search across messages in a mailbox. + * + * @param emailAddress - Full email address of the mailbox to search. + * @param options.q - Search query string. + * @param options.limit - Maximum number of results (1–100). Defaults to 50. + */ + async search( + emailAddress: string, + options: { q: string; limit?: number }, + ): Promise { + const data = await this.http.get>( + `${BASE}/${emailAddress}/search`, + { q: options.q, limit: options.limit ?? 50 }, + ); + return data.items.map(parseMessage); + } +} diff --git a/typescript/src/resources/messages.ts b/typescript/src/mail/resources/messages.ts similarity index 62% rename from typescript/src/resources/messages.ts rename to typescript/src/mail/resources/messages.ts index 3ec5da5..7173dc6 100644 --- a/typescript/src/resources/messages.ts +++ b/typescript/src/mail/resources/messages.ts @@ -4,7 +4,7 @@ * Message operations: list (auto-paginated), get, send, flag updates, delete. */ -import { HttpTransport } from "../_http.js"; +import { HttpTransport } from "../../_http.js"; import { Message, MessageDetail, @@ -26,22 +26,24 @@ export class MessagesResource { * * @example * ```ts - * for await (const msg of client.messages.list(mailboxId)) { + * for await (const msg of client.messages.list(emailAddress)) { * console.log(msg.subject, msg.fromAddress); * } * ``` */ async *list( - mailboxId: string, - options?: { pageSize?: number }, + emailAddress: string, + options?: { pageSize?: number; direction?: "inbound" | "outbound" }, ): AsyncGenerator { const limit = options?.pageSize ?? DEFAULT_PAGE_SIZE; let cursor: string | undefined; while (true) { + const params: Record = { limit, cursor }; + if (options?.direction !== undefined) params["direction"] = options.direction; const page = await this.http.get>( - `/mailboxes/${mailboxId}/messages`, - { limit, cursor }, + `/mailboxes/${emailAddress}/messages`, + params, ); for (const item of page.items) { yield parseMessage(item); @@ -54,12 +56,12 @@ export class MessagesResource { /** * Get a message with full body content. * - * @param mailboxId - UUID of the owning mailbox. + * @param emailAddress - Full email address of the owning mailbox. * @param messageId - UUID of the message. */ - async get(mailboxId: string, messageId: string): Promise { + async get(emailAddress: string, messageId: string): Promise { const data = await this.http.get( - `/mailboxes/${mailboxId}/messages/${messageId}`, + `/mailboxes/${emailAddress}/messages/${messageId}`, ); return parseMessageDetail(data); } @@ -67,7 +69,7 @@ export class MessagesResource { /** * Send an email from a mailbox. * - * @param mailboxId - UUID of the sending mailbox. + * @param emailAddress - Full email address of the sending mailbox. * @param options.to - Primary recipient addresses (at least one required). * @param options.subject - Email subject line. * @param options.bodyText - Plain-text body. @@ -81,7 +83,7 @@ export class MessagesResource { * file content). Max total size: 25 MB. Blocked: `.exe`, `.bat`, `.scr`. */ async send( - mailboxId: string, + emailAddress: string, options: { to: string[]; subject: string; @@ -119,7 +121,7 @@ export class MessagesResource { } const data = await this.http.post( - `/mailboxes/${mailboxId}/messages`, + `/mailboxes/${emailAddress}/messages`, body, ); return parseMessage(data); @@ -131,7 +133,7 @@ export class MessagesResource { * Pass only the flags you want to change; omitted flags are left as-is. */ async updateFlags( - mailboxId: string, + emailAddress: string, messageId: string, flags: { isRead?: boolean; isStarred?: boolean }, ): Promise { @@ -140,34 +142,55 @@ export class MessagesResource { if (flags.isStarred !== undefined) body["is_starred"] = flags.isStarred; const data = await this.http.patch( - `/mailboxes/${mailboxId}/messages/${messageId}`, + `/mailboxes/${emailAddress}/messages/${messageId}`, body, ); return parseMessage(data); } /** Mark a message as read. */ - async markRead(mailboxId: string, messageId: string): Promise { - return this.updateFlags(mailboxId, messageId, { isRead: true }); + async markRead(emailAddress: string, messageId: string): Promise { + return this.updateFlags(emailAddress, messageId, { isRead: true }); } /** Mark a message as unread. */ - async markUnread(mailboxId: string, messageId: string): Promise { - return this.updateFlags(mailboxId, messageId, { isRead: false }); + async markUnread(emailAddress: string, messageId: string): Promise { + return this.updateFlags(emailAddress, messageId, { isRead: false }); } /** Star a message. */ - async star(mailboxId: string, messageId: string): Promise { - return this.updateFlags(mailboxId, messageId, { isStarred: true }); + async star(emailAddress: string, messageId: string): Promise { + return this.updateFlags(emailAddress, messageId, { isStarred: true }); } /** Unstar a message. */ - async unstar(mailboxId: string, messageId: string): Promise { - return this.updateFlags(mailboxId, messageId, { isStarred: false }); + async unstar(emailAddress: string, messageId: string): Promise { + return this.updateFlags(emailAddress, messageId, { isStarred: false }); } /** Delete a message. */ - async delete(mailboxId: string, messageId: string): Promise { - await this.http.delete(`/mailboxes/${mailboxId}/messages/${messageId}`); + async delete(emailAddress: string, messageId: string): Promise { + await this.http.delete(`/mailboxes/${emailAddress}/messages/${messageId}`); + } + + /** + * Get a presigned URL for a message attachment. + * + * @param emailAddress - Full email address of the owning mailbox. + * @param messageId - UUID of the message. + * @param filename - Attachment filename. + * @param options.redirect - If `true`, follows the redirect. If `false` (default), + * returns `{ url, filename, expiresIn }`. + */ + async getAttachment( + emailAddress: string, + messageId: string, + filename: string, + options?: { redirect?: boolean }, + ): Promise<{ url: string; filename: string; expiresIn: number }> { + return this.http.get( + `/mailboxes/${emailAddress}/messages/${messageId}/attachments/${filename}`, + { redirect: options?.redirect ? "true" : "false" }, + ); } } diff --git a/typescript/src/resources/threads.ts b/typescript/src/mail/resources/threads.ts similarity index 71% rename from typescript/src/resources/threads.ts rename to typescript/src/mail/resources/threads.ts index dc0cdc8..c2ba11b 100644 --- a/typescript/src/resources/threads.ts +++ b/typescript/src/mail/resources/threads.ts @@ -4,7 +4,7 @@ * Thread operations: list (auto-paginated), get with messages, delete. */ -import { HttpTransport } from "../_http.js"; +import { HttpTransport } from "../../_http.js"; import { RawCursorPage, RawThread, @@ -26,13 +26,13 @@ export class ThreadsResource { * * @example * ```ts - * for await (const thread of client.threads.list(mailboxId)) { + * for await (const thread of client.threads.list(emailAddress)) { * console.log(thread.subject, thread.messageCount); * } * ``` */ async *list( - mailboxId: string, + emailAddress: string, options?: { pageSize?: number }, ): AsyncGenerator { const limit = options?.pageSize ?? DEFAULT_PAGE_SIZE; @@ -40,7 +40,7 @@ export class ThreadsResource { while (true) { const page = await this.http.get>( - `/mailboxes/${mailboxId}/threads`, + `/mailboxes/${emailAddress}/threads`, { limit, cursor }, ); for (const item of page.items) { @@ -54,18 +54,18 @@ export class ThreadsResource { /** * Get a thread with all its messages inlined. * - * @param mailboxId - UUID of the owning mailbox. + * @param emailAddress - Full email address of the owning mailbox. * @param threadId - UUID of the thread. */ - async get(mailboxId: string, threadId: string): Promise { + async get(emailAddress: string, threadId: string): Promise { const data = await this.http.get( - `/mailboxes/${mailboxId}/threads/${threadId}`, + `/mailboxes/${emailAddress}/threads/${threadId}`, ); return parseThreadDetail(data); } /** Delete a thread. */ - async delete(mailboxId: string, threadId: string): Promise { - await this.http.delete(`/mailboxes/${mailboxId}/threads/${threadId}`); + async delete(emailAddress: string, threadId: string): Promise { + await this.http.delete(`/mailboxes/${emailAddress}/threads/${threadId}`); } } diff --git a/typescript/src/types.ts b/typescript/src/mail/types.ts similarity index 80% rename from typescript/src/types.ts rename to typescript/src/mail/types.ts index 1590931..3d36c09 100644 --- a/typescript/src/types.ts +++ b/typescript/src/mail/types.ts @@ -6,6 +6,7 @@ export interface Mailbox { id: string; emailAddress: string; displayName: string | null; + webhookUrl: string | null; /** "active" | "paused" | "deleted" */ status: string; createdAt: Date; @@ -61,27 +62,13 @@ export interface ThreadDetail extends Thread { messages: Message[]; } -export interface Webhook { - id: string; - mailboxId: string; - url: string; - eventTypes: string[]; - /** "active" | "paused" | "deleted" */ - status: string; - createdAt: Date; -} - -export interface WebhookCreateResult extends Webhook { - /** One-time HMAC-SHA256 signing secret. Save immediately — not returned again. */ - secret: string; -} - // ---- internal raw API shapes (snake_case from JSON) ---- export interface RawMailbox { id: string; email_address: string; display_name: string | null; + webhook_url: string | null; status: string; created_at: string; updated_at: string; @@ -125,20 +112,6 @@ export interface RawThread { messages?: RawMessage[]; } -export interface RawWebhook { - id: string; - mailbox_id: string; - url: string; - event_types: string[]; - status: string; - created_at: string; -} - -export interface RawWebhookCreateResult extends RawWebhook { - /** One-time HMAC-SHA256 signing secret. Save immediately — not returned again. */ - secret: string; -} - export interface RawCursorPage { items: T[]; next_cursor: string | null; @@ -152,6 +125,7 @@ export function parseMailbox(r: RawMailbox): Mailbox { id: r.id, emailAddress: r.email_address, displayName: r.display_name, + webhookUrl: r.webhook_url, status: r.status, createdAt: new Date(r.created_at), updatedAt: new Date(r.updated_at), @@ -211,17 +185,3 @@ export function parseThreadDetail(r: RawThread): ThreadDetail { }; } -export function parseWebhook(r: RawWebhook): Webhook { - return { - id: r.id, - mailboxId: r.mailbox_id, - url: r.url, - eventTypes: r.event_types, - status: r.status, - createdAt: new Date(r.created_at), - }; -} - -export function parseWebhookCreateResult(r: RawWebhookCreateResult): WebhookCreateResult { - return { ...parseWebhook(r), secret: r.secret }; -} diff --git a/typescript/src/phone/resources/calls.ts b/typescript/src/phone/resources/calls.ts new file mode 100644 index 0000000..c39396f --- /dev/null +++ b/typescript/src/phone/resources/calls.ts @@ -0,0 +1,79 @@ +/** + * inkbox-phone/resources/calls.ts + * + * Call operations: list, get, place. + */ + +import { HttpTransport } from "../../_http.js"; +import { + PhoneCall, + PhoneCallWithRateLimit, + RawPhoneCall, + RawPhoneCallWithRateLimit, + parsePhoneCall, + parsePhoneCallWithRateLimit, +} from "../types.js"; + +export class CallsResource { + constructor(private readonly http: HttpTransport) {} + + /** + * List calls for a phone number, newest first. + * + * @param phoneNumberId - UUID of the phone number. + * @param options.limit - Max results (1–200). Defaults to 50. + * @param options.offset - Pagination offset. Defaults to 0. + */ + async list( + phoneNumberId: string, + options?: { limit?: number; offset?: number }, + ): Promise { + const data = await this.http.get( + `/numbers/${phoneNumberId}/calls`, + { limit: options?.limit ?? 50, offset: options?.offset ?? 0 }, + ); + return data.map(parsePhoneCall); + } + + /** + * Get a single call by ID. + * + * @param phoneNumberId - UUID of the phone number. + * @param callId - UUID of the call. + */ + async get(phoneNumberId: string, callId: string): Promise { + const data = await this.http.get( + `/numbers/${phoneNumberId}/calls/${callId}`, + ); + return parsePhoneCall(data); + } + + /** + * Place an outbound call. + * + * @param options.fromNumber - E.164 number to call from. Must belong to your org and be active. + * @param options.toNumber - E.164 number to call. + * @param options.clientWebsocketUrl - WebSocket URL (wss://) for audio bridging. + * @param options.webhookUrl - Custom webhook URL for call lifecycle events. + * @returns The created call record with current rate limit info. + */ + async place(options: { + fromNumber: string; + toNumber: string; + clientWebsocketUrl?: string; + webhookUrl?: string; + }): Promise { + const body: Record = { + from_number: options.fromNumber, + to_number: options.toNumber, + }; + if (options.clientWebsocketUrl !== undefined) { + body["client_websocket_url"] = options.clientWebsocketUrl; + } + if (options.webhookUrl !== undefined) { + body["webhook_url"] = options.webhookUrl; + } + const data = await this.http.post("/place-call", body); + return parsePhoneCallWithRateLimit(data); + } +} diff --git a/typescript/src/phone/resources/numbers.ts b/typescript/src/phone/resources/numbers.ts new file mode 100644 index 0000000..9792638 --- /dev/null +++ b/typescript/src/phone/resources/numbers.ts @@ -0,0 +1,120 @@ +/** + * inkbox-phone/resources/numbers.ts + * + * Phone number CRUD, provisioning, release, and transcript search. + */ + +import { HttpTransport } from "../../_http.js"; +import { + PhoneNumber, + PhoneTranscript, + RawPhoneNumber, + RawPhoneTranscript, + parsePhoneNumber, + parsePhoneTranscript, +} from "../types.js"; + +const BASE = "/numbers"; + +export class PhoneNumbersResource { + constructor(private readonly http: HttpTransport) {} + + /** List all phone numbers for your organisation. */ + async list(): Promise { + const data = await this.http.get(BASE); + return data.map(parsePhoneNumber); + } + + /** Get a phone number by ID. */ + async get(phoneNumberId: string): Promise { + const data = await this.http.get( + `${BASE}/${phoneNumberId}`, + ); + return parsePhoneNumber(data); + } + + /** + * Update phone number settings. Only provided fields are updated. + * Pass a field as `null` to clear it. + * + * @param phoneNumberId - UUID of the phone number. + * @param options.incomingCallAction - `"auto_accept"`, `"auto_reject"`, or `"webhook"`. + * @param options.clientWebsocketUrl - WebSocket URL (wss://) for audio bridging. + * @param options.incomingCallWebhookUrl - Webhook URL called for incoming calls when action is `"webhook"`. + */ + async update( + phoneNumberId: string, + options: { + incomingCallAction?: string; + clientWebsocketUrl?: string | null; + incomingCallWebhookUrl?: string | null; + }, + ): Promise { + const body: Record = {}; + if (options.incomingCallAction !== undefined) { + body["incoming_call_action"] = options.incomingCallAction; + } + if ("clientWebsocketUrl" in options) { + body["client_websocket_url"] = options.clientWebsocketUrl; + } + if ("incomingCallWebhookUrl" in options) { + body["incoming_call_webhook_url"] = options.incomingCallWebhookUrl; + } + const data = await this.http.patch( + `${BASE}/${phoneNumberId}`, + body, + ); + return parsePhoneNumber(data); + } + + /** + * Provision a new phone number and link it to an agent identity. + * + * @param options.agentHandle - Handle of the agent identity to assign this number to. + * @param options.type - `"toll_free"` or `"local"`. Defaults to `"toll_free"`. + * @param options.state - US state abbreviation (e.g. `"NY"`). Only valid for `local` numbers. + */ + async provision(options: { + agentHandle: string; + type?: string; + state?: string; + }): Promise { + const body: Record = { + agent_handle: options.agentHandle, + type: options.type ?? "toll_free", + }; + if (options.state !== undefined) { + body["state"] = options.state; + } + const data = await this.http.post(BASE, body); + return parsePhoneNumber(data); + } + + /** + * Release a phone number. + * + * @param phoneNumberId - UUID of the phone number to release. + */ + async release(phoneNumberId: string): Promise { + await this.http.delete(`${BASE}/${phoneNumberId}`); + } + + /** + * Full-text search across transcripts for a phone number. + * + * @param phoneNumberId - UUID of the phone number. + * @param options.q - Search query string. + * @param options.party - Filter by speaker: `"local"` or `"remote"`. + * @param options.limit - Maximum number of results (1–200). Defaults to 50. + */ + async searchTranscripts( + phoneNumberId: string, + options: { q: string; party?: string; limit?: number }, + ): Promise { + const data = await this.http.get( + `${BASE}/${phoneNumberId}/search`, + { q: options.q, party: options.party, limit: options.limit ?? 50 }, + ); + return data.map(parsePhoneTranscript); + } +} diff --git a/typescript/src/phone/resources/transcripts.ts b/typescript/src/phone/resources/transcripts.ts new file mode 100644 index 0000000..97f4f71 --- /dev/null +++ b/typescript/src/phone/resources/transcripts.ts @@ -0,0 +1,32 @@ +/** + * inkbox-phone/resources/transcripts.ts + * + * Transcript retrieval. + */ + +import { HttpTransport } from "../../_http.js"; +import { + PhoneTranscript, + RawPhoneTranscript, + parsePhoneTranscript, +} from "../types.js"; + +export class TranscriptsResource { + constructor(private readonly http: HttpTransport) {} + + /** + * List all transcript segments for a call, ordered by sequence number. + * + * @param phoneNumberId - UUID of the phone number. + * @param callId - UUID of the call. + */ + async list( + phoneNumberId: string, + callId: string, + ): Promise { + const data = await this.http.get( + `/numbers/${phoneNumberId}/calls/${callId}/transcripts`, + ); + return data.map(parsePhoneTranscript); + } +} diff --git a/typescript/src/phone/types.ts b/typescript/src/phone/types.ts new file mode 100644 index 0000000..91dd336 --- /dev/null +++ b/typescript/src/phone/types.ts @@ -0,0 +1,181 @@ +/** + * inkbox-phone TypeScript SDK — public types. + */ + +export interface PhoneNumber { + id: string; + number: string; + /** "toll_free" | "local" */ + type: string; + /** "active" | "paused" | "released" */ + status: string; + /** "auto_accept" | "auto_reject" | "webhook" */ + incomingCallAction: string; + clientWebsocketUrl: string | null; + incomingCallWebhookUrl: string | null; + createdAt: Date; + updatedAt: Date; +} + +export interface PhoneCall { + id: string; + localPhoneNumber: string; + remotePhoneNumber: string; + /** "outbound" | "inbound" */ + direction: string; + /** "initiated" | "ringing" | "answered" | "completed" | "failed" | "canceled" */ + status: string; + clientWebsocketUrl: string | null; + useInkboxTts: boolean | null; + useInkboxStt: boolean | null; + /** "local" | "remote" | "max_duration" | "voicemail" | "rejected" */ + hangupReason: string | null; + startedAt: Date | null; + endedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export interface RateLimitInfo { + callsUsed: number; + callsRemaining: number; + callsLimit: number; + minutesUsed: number; + minutesRemaining: number; + minutesLimit: number; +} + +export interface PhoneCallWithRateLimit extends PhoneCall { + rateLimit: RateLimitInfo; +} + +export interface PhoneTranscript { + id: string; + callId: string; + seq: number; + tsMs: number; + /** "local" | "remote" | "system" */ + party: string; + text: string; + createdAt: Date; +} + +// ---- internal raw API shapes (snake_case from JSON) ---- + +export interface RawPhoneNumber { + id: string; + number: string; + type: string; + status: string; + incoming_call_action: string; + client_websocket_url: string | null; + incoming_call_webhook_url: string | null; + created_at: string; + updated_at: string; +} + +export interface RawPhoneCall { + id: string; + local_phone_number: string; + remote_phone_number: string; + direction: string; + status: string; + client_websocket_url: string | null; + use_inkbox_tts: boolean | null; + use_inkbox_stt: boolean | null; + hangup_reason: string | null; + started_at: string | null; + ended_at: string | null; + created_at: string; + updated_at: string; +} + +export interface RawRateLimitInfo { + calls_used: number; + calls_remaining: number; + calls_limit: number; + minutes_used: number; + minutes_remaining: number; + minutes_limit: number; +} + +export interface RawPhoneCallWithRateLimit extends RawPhoneCall { + rate_limit: RawRateLimitInfo; +} + +export interface RawPhoneTranscript { + id: string; + call_id: string; + seq: number; + ts_ms: number; + party: string; + text: string; + created_at: string; +} + +// ---- parsers ---- + +export function parsePhoneNumber(r: RawPhoneNumber): PhoneNumber { + return { + id: r.id, + number: r.number, + type: r.type, + status: r.status, + incomingCallAction: r.incoming_call_action, + clientWebsocketUrl: r.client_websocket_url, + incomingCallWebhookUrl: r.incoming_call_webhook_url, + createdAt: new Date(r.created_at), + updatedAt: new Date(r.updated_at), + }; +} + +export function parsePhoneCall(r: RawPhoneCall): PhoneCall { + return { + id: r.id, + localPhoneNumber: r.local_phone_number, + remotePhoneNumber: r.remote_phone_number, + direction: r.direction, + status: r.status, + clientWebsocketUrl: r.client_websocket_url, + useInkboxTts: r.use_inkbox_tts, + useInkboxStt: r.use_inkbox_stt, + hangupReason: r.hangup_reason, + startedAt: r.started_at ? new Date(r.started_at) : null, + endedAt: r.ended_at ? new Date(r.ended_at) : null, + createdAt: new Date(r.created_at), + updatedAt: new Date(r.updated_at), + }; +} + +export function parseRateLimitInfo(r: RawRateLimitInfo): RateLimitInfo { + return { + callsUsed: r.calls_used, + callsRemaining: r.calls_remaining, + callsLimit: r.calls_limit, + minutesUsed: r.minutes_used, + minutesRemaining: r.minutes_remaining, + minutesLimit: r.minutes_limit, + }; +} + +export function parsePhoneCallWithRateLimit( + r: RawPhoneCallWithRateLimit, +): PhoneCallWithRateLimit { + return { + ...parsePhoneCall(r), + rateLimit: parseRateLimitInfo(r.rate_limit), + }; +} + +export function parsePhoneTranscript(r: RawPhoneTranscript): PhoneTranscript { + return { + id: r.id, + callId: r.call_id, + seq: r.seq, + tsMs: r.ts_ms, + party: r.party, + text: r.text, + createdAt: new Date(r.created_at), + }; +} + diff --git a/typescript/src/resources/mailboxes.ts b/typescript/src/resources/mailboxes.ts deleted file mode 100644 index 30997a4..0000000 --- a/typescript/src/resources/mailboxes.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * inkbox-mail/resources/mailboxes.ts - * - * Mailbox CRUD and full-text search. - */ - -import { HttpTransport } from "../_http.js"; -import { - Mailbox, - Message, - RawCursorPage, - RawMailbox, - RawMessage, - parseMailbox, - parseMessage, -} from "../types.js"; - -const BASE = "/mailboxes"; - -export class MailboxesResource { - constructor(private readonly http: HttpTransport) {} - - /** - * Create a new mailbox. - * - * The email address is automatically generated by the server. - * - * @param displayName - Optional human-readable name shown as the sender. - */ - async create(options?: { displayName?: string }): Promise { - const body: Record = {}; - if (options?.displayName !== undefined) { - body["display_name"] = options.displayName; - } - const data = await this.http.post(BASE, body); - return parseMailbox(data); - } - - /** List all mailboxes for your organisation. */ - async list(): Promise { - const data = await this.http.get(BASE); - return data.map(parseMailbox); - } - - /** Get a mailbox by ID. */ - async get(mailboxId: string): Promise { - const data = await this.http.get(`${BASE}/${mailboxId}`); - return parseMailbox(data); - } - - /** Delete a mailbox. */ - async delete(mailboxId: string): Promise { - await this.http.delete(`${BASE}/${mailboxId}`); - } - - /** - * Full-text search across messages in a mailbox. - * - * @param mailboxId - UUID of the mailbox to search. - * @param q - Search query string. - * @param limit - Maximum number of results (1–100). Defaults to 50. - */ - async search( - mailboxId: string, - options: { q: string; limit?: number }, - ): Promise { - const data = await this.http.get>( - `${BASE}/${mailboxId}/search`, - { q: options.q, limit: options.limit ?? 50 }, - ); - return data.items.map(parseMessage); - } -} diff --git a/typescript/src/resources/webhooks.ts b/typescript/src/resources/webhooks.ts deleted file mode 100644 index 12d48db..0000000 --- a/typescript/src/resources/webhooks.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * inkbox-mail/resources/webhooks.ts - * - * Webhook CRUD. - */ - -import { HttpTransport } from "../_http.js"; -import { - RawWebhook, - RawWebhookCreateResult, - Webhook, - WebhookCreateResult, - parseWebhook, - parseWebhookCreateResult, -} from "../types.js"; - -export class WebhooksResource { - constructor(private readonly http: HttpTransport) {} - - /** - * Register a webhook subscription for a mailbox. - * - * @param mailboxId - UUID of the mailbox to subscribe to. - * @param options.url - HTTPS endpoint that will receive webhook POST requests. - * @param options.eventTypes - Events to subscribe to. - * Valid values: `"message.received"`, `"message.sent"`. - * @returns The created webhook. `secret` is the one-time HMAC-SHA256 signing - * key — save it immediately, as it will not be returned again. - */ - async create( - mailboxId: string, - options: { url: string; eventTypes: string[] }, - ): Promise { - const data = await this.http.post( - `/mailboxes/${mailboxId}/webhooks`, - { url: options.url, event_types: options.eventTypes }, - ); - return parseWebhookCreateResult(data); - } - - /** List all active webhooks for a mailbox. */ - async list(mailboxId: string): Promise { - const data = await this.http.get( - `/mailboxes/${mailboxId}/webhooks`, - ); - return data.map(parseWebhook); - } - - /** Delete a webhook subscription. */ - async delete(mailboxId: string, webhookId: string): Promise { - await this.http.delete(`/mailboxes/${mailboxId}/webhooks/${webhookId}`); - } -} diff --git a/typescript/src/signing_keys.ts b/typescript/src/signing_keys.ts new file mode 100644 index 0000000..430c817 --- /dev/null +++ b/typescript/src/signing_keys.ts @@ -0,0 +1,80 @@ +/** + * Org-level webhook signing key management. + * + * Shared across all Inkbox clients (mail, phone, etc.). + */ + +import { createHmac, timingSafeEqual } from "crypto"; +import { HttpTransport } from "./_http.js"; + +const PATH = "/signing-keys"; + +export interface SigningKey { + /** Plaintext signing key — returned once on creation/rotation. Store securely. */ + signingKey: string; + createdAt: Date; +} + +interface RawSigningKey { + signing_key: string; + created_at: string; +} + +function parseSigningKey(r: RawSigningKey): SigningKey { + return { + signingKey: r.signing_key, + createdAt: new Date(r.created_at), + }; +} + +/** + * Verify that an incoming webhook request was sent by Inkbox. + * + * @param payload - Raw request body as a Buffer or string. + * @param headers - Request headers object (keys are lowercased internally). + * @param secret - Your signing key, with or without a `whsec_` prefix. + * @returns True if the signature is valid. + */ +export function verifyWebhook({ + payload, + headers, + secret, +}: { + payload: Buffer | string; + headers: Record; + secret: string; +}): boolean { + const h: Record = {}; + for (const [k, v] of Object.entries(headers)) { + h[k.toLowerCase()] = Array.isArray(v) ? v[0] : v; + } + const signature = h["x-inkbox-signature"] ?? ""; + const requestId = h["x-inkbox-request-id"] ?? ""; + const timestamp = h["x-inkbox-timestamp"] ?? ""; + if (!signature.startsWith("sha256=")) return false; + const key = secret.startsWith("whsec_") ? secret.slice("whsec_".length) : secret; + const body = typeof payload === "string" ? Buffer.from(payload) : payload; + const message = Buffer.concat([Buffer.from(`${requestId}.${timestamp}.`), body]); + const expected = createHmac("sha256", key).update(message).digest("hex"); + const received = signature.slice("sha256=".length); + return timingSafeEqual(Buffer.from(expected), Buffer.from(received)); +} + +export class SigningKeysResource { + constructor(private readonly http: HttpTransport) {} + + /** + * Create or rotate the webhook signing key for your organisation. + * + * The first call creates a new key; subsequent calls rotate (replace) the + * existing key. The plaintext `signingKey` is returned **once** — + * store it securely as it cannot be retrieved again. + * + * Use the returned key to verify `X-Inkbox-Signature` headers on + * incoming webhook requests. + */ + async createOrRotate(): Promise { + const data = await this.http.post(PATH, {}); + return parseSigningKey(data); + } +} diff --git a/typescript/tests/errors.test.ts b/typescript/tests/errors.test.ts new file mode 100644 index 0000000..16159e0 --- /dev/null +++ b/typescript/tests/errors.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { InkboxAPIError } from "../src/_http.js"; + +describe("InkboxAPIError", () => { + it("formats the message correctly", () => { + const err = new InkboxAPIError(404, "not found"); + expect(err.message).toBe("HTTP 404: not found"); + }); + + it("exposes statusCode and detail", () => { + const err = new InkboxAPIError(422, "validation error"); + expect(err.statusCode).toBe(422); + expect(err.detail).toBe("validation error"); + }); + + it("sets name to InkboxAPIError", () => { + const err = new InkboxAPIError(500, "server error"); + expect(err.name).toBe("InkboxAPIError"); + }); + + it("is an instance of Error", () => { + const err = new InkboxAPIError(403, "forbidden"); + expect(err).toBeInstanceOf(Error); + }); +}); diff --git a/typescript/tests/identities/agent_identity.test.ts b/typescript/tests/identities/agent_identity.test.ts new file mode 100644 index 0000000..078e5a6 --- /dev/null +++ b/typescript/tests/identities/agent_identity.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, vi } from "vitest"; +import { AgentIdentity } from "../../src/agent_identity.js"; +import { parseAgentIdentityData } from "../../src/identities/types.js"; +import { parseThreadDetail } from "../../src/mail/types.js"; +import type { Inkbox } from "../../src/inkbox.js"; +import { InkboxAPIError } from "../../src/_http.js"; +import { RAW_IDENTITY_DETAIL, RAW_THREAD_DETAIL } from "../sampleData.js"; + +const THREAD_ID = RAW_THREAD_DETAIL.id; + +function mockInkbox() { + return { + _threads: { get: vi.fn() }, + } as unknown as Inkbox; +} + +function identityWithMailbox() { + const data = parseAgentIdentityData(RAW_IDENTITY_DETAIL); + const inkbox = mockInkbox(); + return { identity: new AgentIdentity(data, inkbox), inkbox }; +} + +function identityWithoutMailbox() { + const data = parseAgentIdentityData({ ...RAW_IDENTITY_DETAIL, mailbox: null }); + const inkbox = mockInkbox(); + return { identity: new AgentIdentity(data, inkbox), inkbox }; +} + +describe("AgentIdentity.getThread", () => { + it("fetches thread detail from identity mailbox", async () => { + const { identity, inkbox } = identityWithMailbox(); + const threadDetail = parseThreadDetail(RAW_THREAD_DETAIL); + vi.mocked(inkbox._threads.get).mockResolvedValue(threadDetail); + + const result = await identity.getThread(THREAD_ID); + + expect(inkbox._threads.get).toHaveBeenCalledWith("sales-agent@inkbox.ai", THREAD_ID); + expect(result.id).toBe(THREAD_ID); + expect(result.messages).toHaveLength(1); + }); + + it("throws when no mailbox is assigned", async () => { + const { identity } = identityWithoutMailbox(); + + await expect(identity.getThread(THREAD_ID)).rejects.toThrow(InkboxAPIError); + }); +}); diff --git a/typescript/tests/identities/identities.test.ts b/typescript/tests/identities/identities.test.ts new file mode 100644 index 0000000..d1aeef7 --- /dev/null +++ b/typescript/tests/identities/identities.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, vi } from "vitest"; +import { IdentitiesResource } from "../../src/identities/resources/identities.js"; +import type { HttpTransport } from "../../src/_http.js"; +import { RAW_IDENTITY, RAW_IDENTITY_DETAIL } from "../sampleData.js"; + +function mockHttp() { + return { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + } as unknown as HttpTransport; +} + +const HANDLE = "sales-agent"; + +describe("IdentitiesResource.create", () => { + it("posts and returns AgentIdentity", async () => { + const http = mockHttp(); + vi.mocked(http.post).mockResolvedValue(RAW_IDENTITY); + const res = new IdentitiesResource(http); + + const identity = await res.create({ agentHandle: HANDLE }); + + expect(http.post).toHaveBeenCalledWith("/", { agent_handle: HANDLE }); + expect(identity.agentHandle).toBe(HANDLE); + }); +}); + +describe("IdentitiesResource.list", () => { + it("returns list of identities", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue([RAW_IDENTITY]); + const res = new IdentitiesResource(http); + + const identities = await res.list(); + + expect(http.get).toHaveBeenCalledWith("/"); + expect(identities).toHaveLength(1); + expect(identities[0].agentHandle).toBe(HANDLE); + }); + + it("returns empty list", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue([]); + const res = new IdentitiesResource(http); + expect(await res.list()).toEqual([]); + }); +}); + +describe("IdentitiesResource.get", () => { + it("returns AgentIdentityDetail", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue(RAW_IDENTITY_DETAIL); + const res = new IdentitiesResource(http); + + const detail = await res.get(HANDLE); + + expect(http.get).toHaveBeenCalledWith(`/${HANDLE}`); + expect(detail.mailbox!.emailAddress).toBe("sales-agent@inkbox.ai"); + }); +}); + +describe("IdentitiesResource.update", () => { + it("sends newHandle", async () => { + const http = mockHttp(); + vi.mocked(http.patch).mockResolvedValue({ ...RAW_IDENTITY, agent_handle: "new-handle" }); + const res = new IdentitiesResource(http); + + const result = await res.update(HANDLE, { newHandle: "new-handle" }); + + expect(http.patch).toHaveBeenCalledWith(`/${HANDLE}`, { agent_handle: "new-handle" }); + expect(result.agentHandle).toBe("new-handle"); + }); + + it("sends status", async () => { + const http = mockHttp(); + vi.mocked(http.patch).mockResolvedValue({ ...RAW_IDENTITY, status: "paused" }); + const res = new IdentitiesResource(http); + + const result = await res.update(HANDLE, { status: "paused" }); + + expect(http.patch).toHaveBeenCalledWith(`/${HANDLE}`, { status: "paused" }); + expect(result.status).toBe("paused"); + }); + + it("omits undefined fields", async () => { + const http = mockHttp(); + vi.mocked(http.patch).mockResolvedValue(RAW_IDENTITY); + const res = new IdentitiesResource(http); + + await res.update(HANDLE, { status: "active" }); + + const [, body] = vi.mocked(http.patch).mock.calls[0] as [string, Record]; + expect(body["agent_handle"]).toBeUndefined(); + }); +}); + +describe("IdentitiesResource.delete", () => { + it("calls delete on the correct path", async () => { + const http = mockHttp(); + vi.mocked(http.delete).mockResolvedValue(undefined); + const res = new IdentitiesResource(http); + + await res.delete(HANDLE); + + expect(http.delete).toHaveBeenCalledWith(`/${HANDLE}`); + }); +}); + +describe("IdentitiesResource.assignMailbox", () => { + it("posts mailbox_id and returns detail", async () => { + const http = mockHttp(); + vi.mocked(http.post).mockResolvedValue(RAW_IDENTITY_DETAIL); + const res = new IdentitiesResource(http); + const mailboxId = "aaaa1111-0000-0000-0000-000000000001"; + + const detail = await res.assignMailbox(HANDLE, { mailboxId }); + + expect(http.post).toHaveBeenCalledWith(`/${HANDLE}/mailbox`, { mailbox_id: mailboxId }); + expect(detail.mailbox!.emailAddress).toBe("sales-agent@inkbox.ai"); + }); +}); + +describe("IdentitiesResource.unlinkMailbox", () => { + it("deletes mailbox link", async () => { + const http = mockHttp(); + vi.mocked(http.delete).mockResolvedValue(undefined); + const res = new IdentitiesResource(http); + + await res.unlinkMailbox(HANDLE); + + expect(http.delete).toHaveBeenCalledWith(`/${HANDLE}/mailbox`); + }); +}); + +describe("IdentitiesResource.assignPhoneNumber", () => { + it("posts phone_number_id and returns detail", async () => { + const http = mockHttp(); + vi.mocked(http.post).mockResolvedValue(RAW_IDENTITY_DETAIL); + const res = new IdentitiesResource(http); + const phoneNumberId = "bbbb2222-0000-0000-0000-000000000001"; + + const detail = await res.assignPhoneNumber(HANDLE, { phoneNumberId }); + + expect(http.post).toHaveBeenCalledWith(`/${HANDLE}/phone_number`, { + phone_number_id: phoneNumberId, + }); + expect(detail.phoneNumber!.number).toBe("+18335794607"); + }); +}); + +describe("IdentitiesResource.unlinkPhoneNumber", () => { + it("deletes phone number link", async () => { + const http = mockHttp(); + vi.mocked(http.delete).mockResolvedValue(undefined); + const res = new IdentitiesResource(http); + + await res.unlinkPhoneNumber(HANDLE); + + expect(http.delete).toHaveBeenCalledWith(`/${HANDLE}/phone_number`); + }); +}); diff --git a/typescript/tests/identities/types.test.ts b/typescript/tests/identities/types.test.ts new file mode 100644 index 0000000..2287b5f --- /dev/null +++ b/typescript/tests/identities/types.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from "vitest"; +import { + parseAgentIdentitySummary, + parseAgentIdentityData, + parseIdentityMailbox, + parseIdentityPhoneNumber, +} from "../../src/identities/types.js"; +import { + RAW_IDENTITY, + RAW_IDENTITY_DETAIL, + RAW_IDENTITY_MAILBOX, + RAW_IDENTITY_PHONE, +} from "../sampleData.js"; + +describe("parseAgentIdentitySummary", () => { + it("converts all fields", () => { + const i = parseAgentIdentitySummary(RAW_IDENTITY); + expect(i.id).toBe(RAW_IDENTITY.id); + expect(i.organizationId).toBe("org-abc123"); + expect(i.agentHandle).toBe("sales-agent"); + expect(i.status).toBe("active"); + expect(i.createdAt).toBeInstanceOf(Date); + expect(i.updatedAt).toBeInstanceOf(Date); + }); +}); + +describe("parseAgentIdentityData", () => { + it("includes nested mailbox and phone number", () => { + const d = parseAgentIdentityData(RAW_IDENTITY_DETAIL); + expect(d.agentHandle).toBe("sales-agent"); + expect(d.mailbox).not.toBeNull(); + expect(d.mailbox!.emailAddress).toBe("sales-agent@inkbox.ai"); + expect(d.phoneNumber).not.toBeNull(); + expect(d.phoneNumber!.number).toBe("+18335794607"); + }); + + it("returns null for missing channels", () => { + const d = parseAgentIdentityData({ ...RAW_IDENTITY, mailbox: null, phone_number: null }); + expect(d.mailbox).toBeNull(); + expect(d.phoneNumber).toBeNull(); + }); +}); + +describe("parseIdentityMailbox", () => { + it("converts all fields", () => { + const m = parseIdentityMailbox(RAW_IDENTITY_MAILBOX); + expect(m.id).toBe(RAW_IDENTITY_MAILBOX.id); + expect(m.emailAddress).toBe("sales-agent@inkbox.ai"); + expect(m.displayName).toBe("Sales Agent"); + expect(m.status).toBe("active"); + expect(m.createdAt).toBeInstanceOf(Date); + expect(m.updatedAt).toBeInstanceOf(Date); + }); +}); + +describe("parseIdentityPhoneNumber", () => { + it("converts all fields", () => { + const p = parseIdentityPhoneNumber(RAW_IDENTITY_PHONE); + expect(p.id).toBe(RAW_IDENTITY_PHONE.id); + expect(p.number).toBe("+18335794607"); + expect(p.type).toBe("toll_free"); + expect(p.incomingCallAction).toBe("auto_reject"); + expect(p.clientWebsocketUrl).toBeNull(); + expect(p.createdAt).toBeInstanceOf(Date); + }); +}); diff --git a/typescript/tests/mail/mailboxes.test.ts b/typescript/tests/mail/mailboxes.test.ts new file mode 100644 index 0000000..b659fcf --- /dev/null +++ b/typescript/tests/mail/mailboxes.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, vi } from "vitest"; +import { MailboxesResource } from "../../src/mail/resources/mailboxes.js"; +import type { HttpTransport } from "../../src/_http.js"; +import { RAW_MAILBOX, RAW_MESSAGE, CURSOR_PAGE_MESSAGES } from "../sampleData.js"; + +function mockHttp() { + return { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + } as unknown as HttpTransport; +} + +const ADDR = "agent01@inkbox.ai"; + +describe("MailboxesResource.create", () => { + it("creates with displayName", async () => { + const http = mockHttp(); + vi.mocked(http.post).mockResolvedValue(RAW_MAILBOX); + const res = new MailboxesResource(http); + + const mailbox = await res.create({ agentHandle: "sales-agent", displayName: "Agent 01" }); + + expect(http.post).toHaveBeenCalledWith("/mailboxes", { + agent_handle: "sales-agent", + display_name: "Agent 01", + }); + expect(mailbox.emailAddress).toBe("agent01@inkbox.ai"); + }); + + it("creates without displayName omits the field", async () => { + const http = mockHttp(); + vi.mocked(http.post).mockResolvedValue(RAW_MAILBOX); + const res = new MailboxesResource(http); + + await res.create({ agentHandle: "sales-agent" }); + + const [, body] = vi.mocked(http.post).mock.calls[0]; + expect((body as Record)["display_name"]).toBeUndefined(); + }); +}); + +describe("MailboxesResource.list", () => { + it("returns array of mailboxes", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue([RAW_MAILBOX]); + const res = new MailboxesResource(http); + + const mailboxes = await res.list(); + + expect(http.get).toHaveBeenCalledWith("/mailboxes"); + expect(mailboxes).toHaveLength(1); + expect(mailboxes[0].emailAddress).toBe("agent01@inkbox.ai"); + }); + + it("returns empty array", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue([]); + const res = new MailboxesResource(http); + expect(await res.list()).toEqual([]); + }); +}); + +describe("MailboxesResource.get", () => { + it("fetches by email address", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue(RAW_MAILBOX); + const res = new MailboxesResource(http); + + const mailbox = await res.get(ADDR); + + expect(http.get).toHaveBeenCalledWith(`/mailboxes/${ADDR}`); + expect(mailbox.displayName).toBe("Agent 01"); + }); +}); + +describe("MailboxesResource.update", () => { + it("sends displayName", async () => { + const http = mockHttp(); + vi.mocked(http.patch).mockResolvedValue({ ...RAW_MAILBOX, display_name: "New Name" }); + const res = new MailboxesResource(http); + + const mailbox = await res.update(ADDR, { displayName: "New Name" }); + + expect(http.patch).toHaveBeenCalledWith(`/mailboxes/${ADDR}`, { display_name: "New Name" }); + expect(mailbox.displayName).toBe("New Name"); + }); + + it("sends empty body when no options provided", async () => { + const http = mockHttp(); + vi.mocked(http.patch).mockResolvedValue(RAW_MAILBOX); + const res = new MailboxesResource(http); + + await res.update(ADDR, {}); + + expect(http.patch).toHaveBeenCalledWith(`/mailboxes/${ADDR}`, {}); + }); +}); + +describe("MailboxesResource.delete", () => { + it("calls delete on the correct path", async () => { + const http = mockHttp(); + vi.mocked(http.delete).mockResolvedValue(undefined); + const res = new MailboxesResource(http); + + await res.delete(ADDR); + + expect(http.delete).toHaveBeenCalledWith(`/mailboxes/${ADDR}`); + }); +}); + +describe("MailboxesResource.search", () => { + it("passes q and default limit", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue(CURSOR_PAGE_MESSAGES); + const res = new MailboxesResource(http); + + const results = await res.search(ADDR, { q: "invoice" }); + + expect(http.get).toHaveBeenCalledWith(`/mailboxes/${ADDR}/search`, { q: "invoice", limit: 50 }); + expect(results).toHaveLength(1); + expect(results[0].subject).toBe("Hello from test"); + }); + + it("passes custom limit", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue({ items: [], next_cursor: null, has_more: false }); + const res = new MailboxesResource(http); + + await res.search(ADDR, { q: "test", limit: 10 }); + + expect(http.get).toHaveBeenCalledWith(`/mailboxes/${ADDR}/search`, { q: "test", limit: 10 }); + }); +}); diff --git a/typescript/tests/mail/messages.test.ts b/typescript/tests/mail/messages.test.ts new file mode 100644 index 0000000..d95ce4e --- /dev/null +++ b/typescript/tests/mail/messages.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, vi } from "vitest"; +import { MessagesResource } from "../../src/mail/resources/messages.js"; +import type { HttpTransport } from "../../src/_http.js"; +import { + RAW_MESSAGE, + RAW_MESSAGE_DETAIL, + CURSOR_PAGE_MESSAGES, + CURSOR_PAGE_MESSAGES_MULTI, +} from "../sampleData.js"; + +function mockHttp() { + return { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + } as unknown as HttpTransport; +} + +const ADDR = "agent01@inkbox.ai"; +const MSG_ID = "bbbb2222-0000-0000-0000-000000000001"; + +async function collect(gen: AsyncGenerator): Promise { + const items: T[] = []; + for await (const item of gen) items.push(item); + return items; +} + +describe("MessagesResource.list", () => { + it("yields messages from a single page", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue(CURSOR_PAGE_MESSAGES); + const res = new MessagesResource(http); + + const messages = await collect(res.list(ADDR)); + + expect(http.get).toHaveBeenCalledTimes(1); + expect(messages).toHaveLength(1); + expect(messages[0].id).toBe(RAW_MESSAGE.id); + }); + + it("paginates through multiple pages", async () => { + const http = mockHttp(); + vi.mocked(http.get) + .mockResolvedValueOnce(CURSOR_PAGE_MESSAGES_MULTI) + .mockResolvedValueOnce(CURSOR_PAGE_MESSAGES); + const res = new MessagesResource(http); + + const messages = await collect(res.list(ADDR)); + + expect(http.get).toHaveBeenCalledTimes(2); + expect(messages).toHaveLength(2); + }); + + it("returns empty for empty page", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue({ items: [], next_cursor: null, has_more: false }); + const res = new MessagesResource(http); + + const messages = await collect(res.list(ADDR)); + expect(messages).toHaveLength(0); + }); +}); + +describe("MessagesResource.get", () => { + it("returns MessageDetail", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue(RAW_MESSAGE_DETAIL); + const res = new MessagesResource(http); + + const detail = await res.get(ADDR, MSG_ID); + + expect(http.get).toHaveBeenCalledWith(`/mailboxes/${ADDR}/messages/${MSG_ID}`); + expect(detail.bodyText).toBe("Hi there, this is a test message body."); + }); +}); + +describe("MessagesResource.send", () => { + it("sends basic message", async () => { + const http = mockHttp(); + vi.mocked(http.post).mockResolvedValue(RAW_MESSAGE); + const res = new MessagesResource(http); + + const msg = await res.send(ADDR, { to: ["user@example.com"], subject: "Hi" }); + + const [path, body] = vi.mocked(http.post).mock.calls[0]; + expect(path).toBe(`/mailboxes/${ADDR}/messages`); + expect((body as Record)["subject"]).toBe("Hi"); + expect(msg.fromAddress).toBe("user@example.com"); + }); + + it("includes all optional fields", async () => { + const http = mockHttp(); + vi.mocked(http.post).mockResolvedValue(RAW_MESSAGE); + const res = new MessagesResource(http); + + await res.send(ADDR, { + to: ["a@example.com"], + subject: "Test", + bodyText: "plain", + bodyHtml: "

html

", + cc: ["b@example.com"], + bcc: ["c@example.com"], + inReplyToMessageId: "", + }); + + const [, body] = vi.mocked(http.post).mock.calls[0] as [string, Record]; + expect(body["body_text"]).toBe("plain"); + expect(body["body_html"]).toBe("

html

"); + expect(body["in_reply_to_message_id"]).toBe(""); + expect((body["recipients"] as Record)["cc"]).toEqual(["b@example.com"]); + }); + + it("omits optional fields when not provided", async () => { + const http = mockHttp(); + vi.mocked(http.post).mockResolvedValue(RAW_MESSAGE); + const res = new MessagesResource(http); + + await res.send(ADDR, { to: ["a@example.com"], subject: "Test" }); + + const [, body] = vi.mocked(http.post).mock.calls[0] as [string, Record]; + expect(body["body_text"]).toBeUndefined(); + expect(body["in_reply_to_message_id"]).toBeUndefined(); + }); +}); + +describe("MessagesResource.updateFlags", () => { + it("sends is_read flag", async () => { + const http = mockHttp(); + vi.mocked(http.patch).mockResolvedValue(RAW_MESSAGE); + const res = new MessagesResource(http); + + await res.updateFlags(ADDR, MSG_ID, { isRead: true }); + + expect(http.patch).toHaveBeenCalledWith( + `/mailboxes/${ADDR}/messages/${MSG_ID}`, + { is_read: true }, + ); + }); + + it("sends both flags", async () => { + const http = mockHttp(); + vi.mocked(http.patch).mockResolvedValue(RAW_MESSAGE); + const res = new MessagesResource(http); + + await res.updateFlags(ADDR, MSG_ID, { isRead: false, isStarred: true }); + + expect(http.patch).toHaveBeenCalledWith( + `/mailboxes/${ADDR}/messages/${MSG_ID}`, + { is_read: false, is_starred: true }, + ); + }); +}); + +describe("MessagesResource convenience methods", () => { + it("markRead delegates to updateFlags", async () => { + const http = mockHttp(); + vi.mocked(http.patch).mockResolvedValue(RAW_MESSAGE); + const res = new MessagesResource(http); + await res.markRead(ADDR, MSG_ID); + expect(http.patch).toHaveBeenCalledWith(expect.any(String), { is_read: true }); + }); + + it("markUnread delegates to updateFlags", async () => { + const http = mockHttp(); + vi.mocked(http.patch).mockResolvedValue(RAW_MESSAGE); + const res = new MessagesResource(http); + await res.markUnread(ADDR, MSG_ID); + expect(http.patch).toHaveBeenCalledWith(expect.any(String), { is_read: false }); + }); + + it("star delegates to updateFlags", async () => { + const http = mockHttp(); + vi.mocked(http.patch).mockResolvedValue(RAW_MESSAGE); + const res = new MessagesResource(http); + await res.star(ADDR, MSG_ID); + expect(http.patch).toHaveBeenCalledWith(expect.any(String), { is_starred: true }); + }); + + it("unstar delegates to updateFlags", async () => { + const http = mockHttp(); + vi.mocked(http.patch).mockResolvedValue(RAW_MESSAGE); + const res = new MessagesResource(http); + await res.unstar(ADDR, MSG_ID); + expect(http.patch).toHaveBeenCalledWith(expect.any(String), { is_starred: false }); + }); +}); + +describe("MessagesResource.delete", () => { + it("calls delete on the correct path", async () => { + const http = mockHttp(); + vi.mocked(http.delete).mockResolvedValue(undefined); + const res = new MessagesResource(http); + + await res.delete(ADDR, MSG_ID); + + expect(http.delete).toHaveBeenCalledWith(`/mailboxes/${ADDR}/messages/${MSG_ID}`); + }); +}); diff --git a/typescript/tests/mail/threads.test.ts b/typescript/tests/mail/threads.test.ts new file mode 100644 index 0000000..f1bac61 --- /dev/null +++ b/typescript/tests/mail/threads.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi } from "vitest"; +import { ThreadsResource } from "../../src/mail/resources/threads.js"; +import type { HttpTransport } from "../../src/_http.js"; +import { RAW_THREAD, RAW_THREAD_DETAIL, CURSOR_PAGE_THREADS } from "../sampleData.js"; + +function mockHttp() { + return { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + } as unknown as HttpTransport; +} + +const ADDR = "agent01@inkbox.ai"; +const THREAD_ID = "eeee5555-0000-0000-0000-000000000001"; + +async function collect(gen: AsyncGenerator): Promise { + const items: T[] = []; + for await (const item of gen) items.push(item); + return items; +} + +describe("ThreadsResource.list", () => { + it("yields threads from a single page", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue(CURSOR_PAGE_THREADS); + const res = new ThreadsResource(http); + + const threads = await collect(res.list(ADDR)); + + expect(http.get).toHaveBeenCalledTimes(1); + expect(threads).toHaveLength(1); + expect(threads[0].subject).toBe("Hello from test"); + }); + + it("paginates through multiple pages", async () => { + const http = mockHttp(); + vi.mocked(http.get) + .mockResolvedValueOnce({ items: [RAW_THREAD], next_cursor: "cur-1", has_more: true }) + .mockResolvedValueOnce(CURSOR_PAGE_THREADS); + const res = new ThreadsResource(http); + + const threads = await collect(res.list(ADDR)); + + expect(http.get).toHaveBeenCalledTimes(2); + expect(threads).toHaveLength(2); + }); + + it("returns empty for empty page", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue({ items: [], next_cursor: null, has_more: false }); + const res = new ThreadsResource(http); + expect(await collect(res.list(ADDR))).toHaveLength(0); + }); +}); + +describe("ThreadsResource.get", () => { + it("returns ThreadDetail with nested messages", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue(RAW_THREAD_DETAIL); + const res = new ThreadsResource(http); + + const detail = await res.get(ADDR, THREAD_ID); + + expect(http.get).toHaveBeenCalledWith(`/mailboxes/${ADDR}/threads/${THREAD_ID}`); + expect(detail.messageCount).toBe(2); + expect(detail.messages).toHaveLength(1); + }); +}); + +describe("ThreadsResource.delete", () => { + it("calls delete on the correct path", async () => { + const http = mockHttp(); + vi.mocked(http.delete).mockResolvedValue(undefined); + const res = new ThreadsResource(http); + + await res.delete(ADDR, THREAD_ID); + + expect(http.delete).toHaveBeenCalledWith(`/mailboxes/${ADDR}/threads/${THREAD_ID}`); + }); +}); diff --git a/typescript/tests/mail/types.test.ts b/typescript/tests/mail/types.test.ts new file mode 100644 index 0000000..53c4871 --- /dev/null +++ b/typescript/tests/mail/types.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from "vitest"; +import { + parseMailbox, + parseMessage, + parseMessageDetail, + parseThread, + parseThreadDetail, +} from "../../src/mail/types.js"; +import { + RAW_MAILBOX, + RAW_MESSAGE, + RAW_MESSAGE_DETAIL, + RAW_THREAD, + RAW_THREAD_DETAIL, +} from "../sampleData.js"; + +describe("parseMailbox", () => { + it("converts snake_case to camelCase with Date instances", () => { + const m = parseMailbox(RAW_MAILBOX); + expect(m.id).toBe(RAW_MAILBOX.id); + expect(m.emailAddress).toBe("agent01@inkbox.ai"); + expect(m.displayName).toBe("Agent 01"); + expect(m.status).toBe("active"); + expect(m.createdAt).toBeInstanceOf(Date); + expect(m.updatedAt).toBeInstanceOf(Date); + }); +}); + +describe("parseMessage", () => { + it("converts all fields", () => { + const msg = parseMessage(RAW_MESSAGE); + expect(msg.id).toBe(RAW_MESSAGE.id); + expect(msg.mailboxId).toBe(RAW_MESSAGE.mailbox_id); + expect(msg.threadId).toBe(RAW_MESSAGE.thread_id); + expect(msg.fromAddress).toBe("user@example.com"); + expect(msg.toAddresses).toEqual(["agent01@inkbox.ai"]); + expect(msg.ccAddresses).toBeNull(); + expect(msg.subject).toBe("Hello from test"); + expect(msg.isRead).toBe(false); + expect(msg.isStarred).toBe(false); + expect(msg.hasAttachments).toBe(false); + expect(msg.createdAt).toBeInstanceOf(Date); + }); + + it("handles null threadId", () => { + const msg = parseMessage({ ...RAW_MESSAGE, thread_id: null }); + expect(msg.threadId).toBeNull(); + }); +}); + +describe("parseMessageDetail", () => { + it("includes body and header fields", () => { + const detail = parseMessageDetail(RAW_MESSAGE_DETAIL); + expect(detail.bodyText).toBe("Hi there, this is a test message body."); + expect(detail.bodyHtml).toBe("

Hi there, this is a test message body.

"); + expect(detail.bccAddresses).toBeNull(); + expect(detail.inReplyTo).toBeNull(); + expect(detail.sesMessageId).toBe("ses-abc123"); + expect(detail.updatedAt).toBeInstanceOf(Date); + }); +}); + +describe("parseThread", () => { + it("converts all fields", () => { + const t = parseThread(RAW_THREAD); + expect(t.id).toBe(RAW_THREAD.id); + expect(t.mailboxId).toBe(RAW_THREAD.mailbox_id); + expect(t.subject).toBe("Hello from test"); + expect(t.messageCount).toBe(2); + expect(t.lastMessageAt).toBeInstanceOf(Date); + expect(t.createdAt).toBeInstanceOf(Date); + }); +}); + +describe("parseThreadDetail", () => { + it("includes nested messages", () => { + const td = parseThreadDetail(RAW_THREAD_DETAIL); + expect(td.messages).toHaveLength(1); + expect(td.messages[0].id).toBe(RAW_MESSAGE.id); + }); + + it("returns empty messages array when missing", () => { + const td = parseThreadDetail({ ...RAW_THREAD }); + expect(td.messages).toEqual([]); + }); +}); + diff --git a/typescript/tests/phone/calls.test.ts b/typescript/tests/phone/calls.test.ts new file mode 100644 index 0000000..dc463f5 --- /dev/null +++ b/typescript/tests/phone/calls.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi } from "vitest"; +import { CallsResource } from "../../src/phone/resources/calls.js"; +import type { HttpTransport } from "../../src/_http.js"; +import { RAW_PHONE_CALL, RAW_PHONE_CALL_WITH_RATE_LIMIT } from "../sampleData.js"; + +function mockHttp() { + return { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + } as unknown as HttpTransport; +} + +const NUM_ID = "aaaa1111-0000-0000-0000-000000000001"; +const CALL_ID = "bbbb2222-0000-0000-0000-000000000001"; + +describe("CallsResource.list", () => { + it("uses default limit and offset", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue([RAW_PHONE_CALL]); + const res = new CallsResource(http); + + const calls = await res.list(NUM_ID); + + expect(http.get).toHaveBeenCalledWith(`/numbers/${NUM_ID}/calls`, { limit: 50, offset: 0 }); + expect(calls).toHaveLength(1); + expect(calls[0].direction).toBe("outbound"); + }); + + it("passes custom limit and offset", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue([]); + const res = new CallsResource(http); + + await res.list(NUM_ID, { limit: 10, offset: 20 }); + + expect(http.get).toHaveBeenCalledWith(`/numbers/${NUM_ID}/calls`, { limit: 10, offset: 20 }); + }); +}); + +describe("CallsResource.get", () => { + it("fetches by ID", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue(RAW_PHONE_CALL); + const res = new CallsResource(http); + + const call = await res.get(NUM_ID, CALL_ID); + + expect(http.get).toHaveBeenCalledWith(`/numbers/${NUM_ID}/calls/${CALL_ID}`); + expect(call.status).toBe("completed"); + }); +}); + +describe("CallsResource.place", () => { + it("places call with required fields", async () => { + const http = mockHttp(); + vi.mocked(http.post).mockResolvedValue(RAW_PHONE_CALL_WITH_RATE_LIMIT); + const res = new CallsResource(http); + + const call = await res.place({ + fromNumber: "+18335794607", + toNumber: "+15167251294", + }); + + expect(http.post).toHaveBeenCalledWith("/place-call", { + from_number: "+18335794607", + to_number: "+15167251294", + }); + expect(call.rateLimit.callsUsed).toBe(5); + }); + + it("includes optional fields when provided", async () => { + const http = mockHttp(); + vi.mocked(http.post).mockResolvedValue(RAW_PHONE_CALL_WITH_RATE_LIMIT); + const res = new CallsResource(http); + + await res.place({ + fromNumber: "+18335794607", + toNumber: "+15167251294", + clientWebsocketUrl: "wss://agent.example.com/ws", + webhookUrl: "https://example.com/hook", + }); + + const [, body] = vi.mocked(http.post).mock.calls[0] as [string, Record]; + expect(body["client_websocket_url"]).toBe("wss://agent.example.com/ws"); + expect(body["webhook_url"]).toBe("https://example.com/hook"); + }); + + it("omits optional fields when not provided", async () => { + const http = mockHttp(); + vi.mocked(http.post).mockResolvedValue(RAW_PHONE_CALL_WITH_RATE_LIMIT); + const res = new CallsResource(http); + + await res.place({ fromNumber: "+18335794607", toNumber: "+15167251294" }); + + const [, body] = vi.mocked(http.post).mock.calls[0] as [string, Record]; + expect(body["client_websocket_url"]).toBeUndefined(); + expect(body["webhook_url"]).toBeUndefined(); + }); +}); diff --git a/typescript/tests/phone/numbers.test.ts b/typescript/tests/phone/numbers.test.ts new file mode 100644 index 0000000..e9f7349 --- /dev/null +++ b/typescript/tests/phone/numbers.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, vi } from "vitest"; +import { PhoneNumbersResource } from "../../src/phone/resources/numbers.js"; +import type { HttpTransport } from "../../src/_http.js"; +import { RAW_PHONE_NUMBER, RAW_PHONE_TRANSCRIPT } from "../sampleData.js"; + +function mockHttp() { + return { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + } as unknown as HttpTransport; +} + +const NUM_ID = "aaaa1111-0000-0000-0000-000000000001"; + +describe("PhoneNumbersResource.list", () => { + it("returns list of phone numbers", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue([RAW_PHONE_NUMBER]); + const res = new PhoneNumbersResource(http); + + const numbers = await res.list(); + + expect(http.get).toHaveBeenCalledWith("/numbers"); + expect(numbers).toHaveLength(1); + expect(numbers[0].number).toBe("+18335794607"); + }); +}); + +describe("PhoneNumbersResource.get", () => { + it("fetches by ID", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue(RAW_PHONE_NUMBER); + const res = new PhoneNumbersResource(http); + + const number = await res.get(NUM_ID); + + expect(http.get).toHaveBeenCalledWith(`/numbers/${NUM_ID}`); + expect(number.incomingCallAction).toBe("auto_reject"); + }); +}); + +describe("PhoneNumbersResource.update", () => { + it("sends incomingCallAction", async () => { + const http = mockHttp(); + vi.mocked(http.patch).mockResolvedValue(RAW_PHONE_NUMBER); + const res = new PhoneNumbersResource(http); + + await res.update(NUM_ID, { incomingCallAction: "auto_accept" }); + + expect(http.patch).toHaveBeenCalledWith(`/numbers/${NUM_ID}`, { + incoming_call_action: "auto_accept", + }); + }); + + it("omits undefined fields", async () => { + const http = mockHttp(); + vi.mocked(http.patch).mockResolvedValue(RAW_PHONE_NUMBER); + const res = new PhoneNumbersResource(http); + + await res.update(NUM_ID, {}); + + expect(http.patch).toHaveBeenCalledWith(`/numbers/${NUM_ID}`, {}); + }); +}); + +describe("PhoneNumbersResource.provision", () => { + it("defaults to toll_free", async () => { + const http = mockHttp(); + vi.mocked(http.post).mockResolvedValue(RAW_PHONE_NUMBER); + const res = new PhoneNumbersResource(http); + + await res.provision({ agentHandle: "sales-agent" }); + + expect(http.post).toHaveBeenCalledWith("/numbers", { + agent_handle: "sales-agent", + type: "toll_free", + }); + }); + + it("passes type and state for local numbers", async () => { + const http = mockHttp(); + vi.mocked(http.post).mockResolvedValue({ ...RAW_PHONE_NUMBER, type: "local" }); + const res = new PhoneNumbersResource(http); + + const number = await res.provision({ agentHandle: "sales-agent", type: "local", state: "NY" }); + + const [, body] = vi.mocked(http.post).mock.calls[0] as [string, Record]; + expect(body["state"]).toBe("NY"); + expect(number.type).toBe("local"); + }); +}); + +describe("PhoneNumbersResource.release", () => { + it("deletes by ID", async () => { + const http = mockHttp(); + vi.mocked(http.delete).mockResolvedValue(undefined); + const res = new PhoneNumbersResource(http); + + await res.release(NUM_ID); + + expect(http.delete).toHaveBeenCalledWith(`/numbers/${NUM_ID}`); + }); +}); + +describe("PhoneNumbersResource.searchTranscripts", () => { + it("passes query and defaults", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue([RAW_PHONE_TRANSCRIPT]); + const res = new PhoneNumbersResource(http); + + const results = await res.searchTranscripts(NUM_ID, { q: "hello" }); + + expect(http.get).toHaveBeenCalledWith(`/numbers/${NUM_ID}/search`, { + q: "hello", + party: undefined, + limit: 50, + }); + expect(results[0].text).toBe("Hello, how can I help you?"); + }); + + it("passes party and custom limit", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue([]); + const res = new PhoneNumbersResource(http); + + await res.searchTranscripts(NUM_ID, { q: "test", party: "remote", limit: 10 }); + + expect(http.get).toHaveBeenCalledWith(`/numbers/${NUM_ID}/search`, { + q: "test", + party: "remote", + limit: 10, + }); + }); +}); diff --git a/typescript/tests/phone/transcripts.test.ts b/typescript/tests/phone/transcripts.test.ts new file mode 100644 index 0000000..d5e757b --- /dev/null +++ b/typescript/tests/phone/transcripts.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, vi } from "vitest"; +import { TranscriptsResource } from "../../src/phone/resources/transcripts.js"; +import type { HttpTransport } from "../../src/_http.js"; +import { RAW_PHONE_TRANSCRIPT } from "../sampleData.js"; + +function mockHttp() { + return { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + } as unknown as HttpTransport; +} + +const NUM_ID = "aaaa1111-0000-0000-0000-000000000001"; +const CALL_ID = "bbbb2222-0000-0000-0000-000000000001"; + +describe("TranscriptsResource.list", () => { + it("returns transcript segments", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue([RAW_PHONE_TRANSCRIPT]); + const res = new TranscriptsResource(http); + + const transcripts = await res.list(NUM_ID, CALL_ID); + + expect(http.get).toHaveBeenCalledWith(`/numbers/${NUM_ID}/calls/${CALL_ID}/transcripts`); + expect(transcripts).toHaveLength(1); + expect(transcripts[0].text).toBe("Hello, how can I help you?"); + expect(transcripts[0].seq).toBe(0); + }); + + it("returns empty array", async () => { + const http = mockHttp(); + vi.mocked(http.get).mockResolvedValue([]); + const res = new TranscriptsResource(http); + expect(await res.list(NUM_ID, CALL_ID)).toEqual([]); + }); +}); diff --git a/typescript/tests/phone/types.test.ts b/typescript/tests/phone/types.test.ts new file mode 100644 index 0000000..1db4053 --- /dev/null +++ b/typescript/tests/phone/types.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest"; +import { + parsePhoneNumber, + parsePhoneCall, + parseRateLimitInfo, + parsePhoneCallWithRateLimit, + parsePhoneTranscript, +} from "../../src/phone/types.js"; +import { + RAW_PHONE_NUMBER, + RAW_PHONE_CALL, + RAW_RATE_LIMIT, + RAW_PHONE_CALL_WITH_RATE_LIMIT, + RAW_PHONE_TRANSCRIPT, +} from "../sampleData.js"; + +describe("parsePhoneNumber", () => { + it("converts all fields", () => { + const n = parsePhoneNumber(RAW_PHONE_NUMBER); + expect(n.id).toBe(RAW_PHONE_NUMBER.id); + expect(n.number).toBe("+18335794607"); + expect(n.type).toBe("toll_free"); + expect(n.status).toBe("active"); + expect(n.incomingCallAction).toBe("auto_reject"); + expect(n.clientWebsocketUrl).toBeNull(); + expect(n.createdAt).toBeInstanceOf(Date); + expect(n.updatedAt).toBeInstanceOf(Date); + }); +}); + +describe("parsePhoneCall", () => { + it("converts all fields", () => { + const c = parsePhoneCall(RAW_PHONE_CALL); + expect(c.id).toBe(RAW_PHONE_CALL.id); + expect(c.localPhoneNumber).toBe("+18335794607"); + expect(c.remotePhoneNumber).toBe("+15167251294"); + expect(c.direction).toBe("outbound"); + expect(c.status).toBe("completed"); + expect(c.clientWebsocketUrl).toBe("wss://agent.example.com/ws"); + expect(c.startedAt).toBeInstanceOf(Date); + expect(c.endedAt).toBeInstanceOf(Date); + }); + + it("handles null timestamps", () => { + const c = parsePhoneCall({ ...RAW_PHONE_CALL, started_at: null, ended_at: null }); + expect(c.startedAt).toBeNull(); + expect(c.endedAt).toBeNull(); + }); +}); + +describe("parseRateLimitInfo", () => { + it("converts all fields", () => { + const r = parseRateLimitInfo(RAW_RATE_LIMIT); + expect(r.callsUsed).toBe(5); + expect(r.callsRemaining).toBe(95); + expect(r.callsLimit).toBe(100); + expect(r.minutesUsed).toBe(12.5); + expect(r.minutesRemaining).toBe(987.5); + expect(r.minutesLimit).toBe(1000); + }); +}); + +describe("parsePhoneCallWithRateLimit", () => { + it("includes rateLimit", () => { + const c = parsePhoneCallWithRateLimit(RAW_PHONE_CALL_WITH_RATE_LIMIT); + expect(c.rateLimit.callsUsed).toBe(5); + expect(c.status).toBe("completed"); + }); +}); + +describe("parsePhoneTranscript", () => { + it("converts all fields", () => { + const t = parsePhoneTranscript(RAW_PHONE_TRANSCRIPT); + expect(t.id).toBe(RAW_PHONE_TRANSCRIPT.id); + expect(t.callId).toBe(RAW_PHONE_TRANSCRIPT.call_id); + expect(t.seq).toBe(0); + expect(t.tsMs).toBe(1500); + expect(t.party).toBe("local"); + expect(t.text).toBe("Hello, how can I help you?"); + expect(t.createdAt).toBeInstanceOf(Date); + }); +}); + diff --git a/typescript/tests/sampleData.ts b/typescript/tests/sampleData.ts new file mode 100644 index 0000000..e469526 --- /dev/null +++ b/typescript/tests/sampleData.ts @@ -0,0 +1,172 @@ +/** Shared raw (snake_case) API response fixtures for tests. */ + +// ---- Mail ---- + +export const RAW_MAILBOX = { + id: "aaaa1111-0000-0000-0000-000000000001", + email_address: "agent01@inkbox.ai", + display_name: "Agent 01", + status: "active", + created_at: "2026-03-09T00:00:00Z", + updated_at: "2026-03-09T00:00:00Z", +}; + +export const RAW_MESSAGE = { + id: "bbbb2222-0000-0000-0000-000000000001", + mailbox_id: "aaaa1111-0000-0000-0000-000000000001", + thread_id: "eeee5555-0000-0000-0000-000000000001", + message_id: "", + from_address: "user@example.com", + to_addresses: ["agent01@inkbox.ai"], + cc_addresses: null, + subject: "Hello from test", + snippet: "Hi there, this is a test message...", + direction: "inbound", + status: "delivered", + is_read: false, + is_starred: false, + has_attachments: false, + created_at: "2026-03-09T00:00:00Z", +}; + +export const RAW_MESSAGE_DETAIL = { + ...RAW_MESSAGE, + body_text: "Hi there, this is a test message body.", + body_html: "

Hi there, this is a test message body.

", + bcc_addresses: null, + in_reply_to: null, + references: null, + attachment_metadata: null, + ses_message_id: "ses-abc123", + updated_at: "2026-03-09T00:00:00Z", +}; + +export const RAW_THREAD = { + id: "eeee5555-0000-0000-0000-000000000001", + mailbox_id: "aaaa1111-0000-0000-0000-000000000001", + subject: "Hello from test", + status: "active", + message_count: 2, + last_message_at: "2026-03-09T00:05:00Z", + created_at: "2026-03-09T00:00:00Z", +}; + +export const RAW_THREAD_DETAIL = { + ...RAW_THREAD, + messages: [RAW_MESSAGE], +}; + +export const CURSOR_PAGE_MESSAGES = { + items: [RAW_MESSAGE], + next_cursor: null, + has_more: false, +}; + +export const CURSOR_PAGE_MESSAGES_MULTI = { + items: [RAW_MESSAGE], + next_cursor: "cursor-abc", + has_more: true, +}; + +export const CURSOR_PAGE_THREADS = { + items: [RAW_THREAD], + next_cursor: null, + has_more: false, +}; + +// ---- Phone ---- + +export const RAW_PHONE_NUMBER = { + id: "aaaa1111-0000-0000-0000-000000000001", + number: "+18335794607", + type: "toll_free", + status: "active", + incoming_call_action: "auto_reject", + client_websocket_url: null, + created_at: "2026-03-09T00:00:00Z", + updated_at: "2026-03-09T00:00:00Z", +}; + +export const RAW_PHONE_CALL = { + id: "bbbb2222-0000-0000-0000-000000000001", + local_phone_number: "+18335794607", + remote_phone_number: "+15167251294", + direction: "outbound", + status: "completed", + client_websocket_url: "wss://agent.example.com/ws", + use_inkbox_tts: null, + use_inkbox_stt: null, + hangup_reason: null, + started_at: "2026-03-09T00:01:00Z", + ended_at: "2026-03-09T00:05:00Z", + created_at: "2026-03-09T00:00:00Z", + updated_at: "2026-03-09T00:05:00Z", +}; + +export const RAW_RATE_LIMIT = { + calls_used: 5, + calls_remaining: 95, + calls_limit: 100, + minutes_used: 12.5, + minutes_remaining: 987.5, + minutes_limit: 1000, +}; + +export const RAW_PHONE_CALL_WITH_RATE_LIMIT = { + ...RAW_PHONE_CALL, + rate_limit: RAW_RATE_LIMIT, +}; + +export const RAW_PHONE_TRANSCRIPT = { + id: "cccc3333-0000-0000-0000-000000000001", + call_id: "bbbb2222-0000-0000-0000-000000000001", + seq: 0, + ts_ms: 1500, + party: "local", + text: "Hello, how can I help you?", + created_at: "2026-03-09T00:01:01Z", +}; + +// ---- Identities ---- + +export const RAW_IDENTITY_MAILBOX = { + id: "aaaa1111-0000-0000-0000-000000000001", + email_address: "sales-agent@inkbox.ai", + display_name: "Sales Agent", + status: "active", + created_at: "2026-03-09T00:00:00Z", + updated_at: "2026-03-09T00:00:00Z", +}; + +export const RAW_IDENTITY_PHONE = { + id: "bbbb2222-0000-0000-0000-000000000001", + number: "+18335794607", + type: "toll_free", + status: "active", + incoming_call_action: "auto_reject", + client_websocket_url: null, + created_at: "2026-03-09T00:00:00Z", + updated_at: "2026-03-09T00:00:00Z", +}; + +export const RAW_IDENTITY = { + id: "eeee5555-0000-0000-0000-000000000001", + organization_id: "org-abc123", + agent_handle: "sales-agent", + status: "active", + created_at: "2026-03-09T00:00:00Z", + updated_at: "2026-03-09T00:00:00Z", +}; + +export const RAW_IDENTITY_DETAIL = { + ...RAW_IDENTITY, + mailbox: RAW_IDENTITY_MAILBOX, + phone_number: RAW_IDENTITY_PHONE, +}; + +// ---- Signing Keys ---- + +export const RAW_SIGNING_KEY = { + signing_key: "sk-test-hmac-secret-abc123", + created_at: "2026-03-09T00:00:00Z", +}; diff --git a/typescript/tests/signing-keys.test.ts b/typescript/tests/signing-keys.test.ts new file mode 100644 index 0000000..863795f --- /dev/null +++ b/typescript/tests/signing-keys.test.ts @@ -0,0 +1,111 @@ +import { createHmac } from "crypto"; +import { describe, it, expect, vi } from "vitest"; +import { SigningKeysResource, verifyWebhook } from "../src/signing_keys.js"; +import { HttpTransport } from "../src/_http.js"; +import { RAW_SIGNING_KEY } from "./sampleData.js"; + +const TEST_KEY = "test-signing-key"; +const TEST_REQUEST_ID = "req-abc-123"; +const TEST_TIMESTAMP = "1741737600"; +const TEST_BODY = Buffer.from('{"event":"message.received"}'); + +function makeSignature(key: string, requestId: string, timestamp: string, body: Buffer): string { + const message = Buffer.concat([Buffer.from(`${requestId}.${timestamp}.`), body]); + const digest = createHmac("sha256", key).update(message).digest("hex"); + return `sha256=${digest}`; +} + +function makeResource() { + const http = { post: vi.fn() } as unknown as HttpTransport; + const resource = new SigningKeysResource(http); + return { resource, http: http as { post: ReturnType } }; +} + +function makeHeaders(sig: string): Record { + return { + "x-inkbox-signature": sig, + "x-inkbox-request-id": TEST_REQUEST_ID, + "x-inkbox-timestamp": TEST_TIMESTAMP, + }; +} + +describe("verifyWebhook", () => { + it("returns true for a valid signature", () => { + const sig = makeSignature(TEST_KEY, TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY); + expect(verifyWebhook({ payload: TEST_BODY, headers: makeHeaders(sig), secret: TEST_KEY })).toBe(true); + }); + + it("accepts whsec_ prefixed secret", () => { + const sig = makeSignature(TEST_KEY, TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY); + expect(verifyWebhook({ payload: TEST_BODY, headers: makeHeaders(sig), secret: `whsec_${TEST_KEY}` })).toBe(true); + }); + + it("accepts string payload", () => { + const sig = makeSignature(TEST_KEY, TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY); + expect(verifyWebhook({ payload: TEST_BODY.toString(), headers: makeHeaders(sig), secret: TEST_KEY })).toBe(true); + }); + + it("normalizes header casing", () => { + const sig = makeSignature(TEST_KEY, TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY); + const uppercaseHeaders = { + "X-Inkbox-Signature": sig, + "X-Inkbox-Request-ID": TEST_REQUEST_ID, + "X-Inkbox-Timestamp": TEST_TIMESTAMP, + }; + expect(verifyWebhook({ payload: TEST_BODY, headers: uppercaseHeaders, secret: TEST_KEY })).toBe(true); + }); + + it("returns false for wrong key", () => { + const sig = makeSignature("wrong-key", TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY); + expect(verifyWebhook({ payload: TEST_BODY, headers: makeHeaders(sig), secret: TEST_KEY })).toBe(false); + }); + + it("returns false for tampered body", () => { + const sig = makeSignature(TEST_KEY, TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY); + expect(verifyWebhook({ payload: Buffer.from('{"event":"message.sent"}'), headers: makeHeaders(sig), secret: TEST_KEY })).toBe(false); + }); + + it("returns false for wrong requestId", () => { + const sig = makeSignature(TEST_KEY, TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY); + expect(verifyWebhook({ payload: TEST_BODY, headers: { ...makeHeaders(sig), "x-inkbox-request-id": "different-id" }, secret: TEST_KEY })).toBe(false); + }); + + it("returns false for wrong timestamp", () => { + const sig = makeSignature(TEST_KEY, TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY); + expect(verifyWebhook({ payload: TEST_BODY, headers: { ...makeHeaders(sig), "x-inkbox-timestamp": "9999999999" }, secret: TEST_KEY })).toBe(false); + }); + + it("returns false when sha256= prefix is missing", () => { + const sig = makeSignature(TEST_KEY, TEST_REQUEST_ID, TEST_TIMESTAMP, TEST_BODY).slice("sha256=".length); + expect(verifyWebhook({ payload: TEST_BODY, headers: makeHeaders(sig), secret: TEST_KEY })).toBe(false); + }); + + it("returns false when headers are missing", () => { + expect(verifyWebhook({ payload: TEST_BODY, headers: {}, secret: TEST_KEY })).toBe(false); + }); +}); + +describe("SigningKeysResource", () => { + describe("createOrRotate", () => { + it("calls POST /signing-keys with empty body", async () => { + const { resource, http } = makeResource(); + http.post.mockResolvedValue(RAW_SIGNING_KEY); + + await resource.createOrRotate(); + + expect(http.post).toHaveBeenCalledOnce(); + expect(http.post).toHaveBeenCalledWith("/signing-keys", {}); + }); + + it("parses signingKey and createdAt from response", async () => { + const { resource, http } = makeResource(); + http.post.mockResolvedValue(RAW_SIGNING_KEY); + + const key = await resource.createOrRotate(); + + expect(key.signingKey).toBe("sk-test-hmac-secret-abc123"); + expect(key.createdAt).toBeInstanceOf(Date); + expect(key.createdAt.toISOString()).toBe("2026-03-09T00:00:00.000Z"); + }); + }); +}); diff --git a/typescript/tsconfig.json b/typescript/tsconfig.json index 09485ae..a1b642d 100644 --- a/typescript/tsconfig.json +++ b/typescript/tsconfig.json @@ -4,6 +4,7 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "lib": ["ES2022", "DOM"], + "types": ["node"], "declaration": true, "declarationMap": true, "sourceMap": true, diff --git a/typescript/vitest.config.ts b/typescript/vitest.config.ts new file mode 100644 index 0000000..f624398 --- /dev/null +++ b/typescript/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + }, +});