diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1964062..2d6df24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,14 @@ name: CI on: push: - branches-ignore: - - 'generated' - - 'codegen/**' - - 'integrated/**' - - 'stl-preview-head/**' - - 'stl-preview-base/**' + branches: + - '**' + - '!integrated/**' + - '!stl-preview-head/**' + - '!stl-preview-base/**' + - '!generated' + - '!codegen/**' + - 'codegen/stl/**' pull_request: branches-ignore: - 'stl-preview-head/**' @@ -24,7 +26,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v5 with: - version: '0.9.13' + version: '0.10.2' - name: Install dependencies run: uv sync --all-extras @@ -46,7 +48,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v5 with: - version: '0.9.13' + version: '0.10.2' - name: Install dependencies run: uv sync --all-extras @@ -55,14 +57,18 @@ jobs: run: uv build - name: Get GitHub OIDC Token - if: github.repository == 'stainless-sdks/ark-python' + if: |- + github.repository == 'stainless-sdks/ark-python' && + !startsWith(github.ref, 'refs/heads/stl/') id: github-oidc uses: actions/github-script@v8 with: script: core.setOutput('github_token', await core.getIDToken()); - name: Upload tarball - if: github.repository == 'stainless-sdks/ark-python' + if: |- + github.repository == 'stainless-sdks/ark-python' && + !startsWith(github.ref, 'refs/heads/stl/') env: URL: https://pkg.stainless.com/s AUTH: ${{ steps.github-oidc.outputs.github_token }} @@ -80,7 +86,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v5 with: - version: '0.9.13' + version: '0.10.2' - name: Bootstrap run: ./scripts/bootstrap diff --git a/.release-please-manifest.json b/.release-please-manifest.json index d661066..e756293 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.18.1" + ".": "0.19.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index c8ee129..e530c9b 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 58 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/ark%2Fark-98a90852ffca49f4e26c613afff433b17023ee1f81f38ad38a5dad60a0d09881.yml -openapi_spec_hash: c6fd865dd6995df15cf9e6ada2ae791e +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/ark%2Fark-06c3025bf12b191c3906b28173c9b359e24481dd2839dbf3e6dd0b80c1de3fd6.yml +openapi_spec_hash: d8f8fb1f78579997b6381d64cba4e826 config_hash: b70b11b10fc614f91f1c6f028b40780f diff --git a/CHANGELOG.md b/CHANGELOG.md index a755aaa..07958ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ # Changelog +## 0.19.0 (2026-03-21) + +Full Changelog: [v0.18.1...v0.19.0](https://github.com/ArkHQ-io/ark-python/compare/v0.18.1...v0.19.0) + +### Features + +* **api:** add tenantId to send ([3eddd67](https://github.com/ArkHQ-io/ark-python/commit/3eddd677b69f387149336e11abe71a6143290ac4)) + + +### Bug Fixes + +* **deps:** bump minimum typing-extensions version ([f968d32](https://github.com/ArkHQ-io/ark-python/commit/f968d32304f3254d028211fd71f3cc4be8a9d61b)) +* **pydantic:** do not pass `by_alias` unless set ([5c61281](https://github.com/ArkHQ-io/ark-python/commit/5c612819f12b4b87159d751625fc2e64d1e3dd1f)) +* sanitize endpoint path params ([d4ca0b1](https://github.com/ArkHQ-io/ark-python/commit/d4ca0b1e86263ef3e7ededd8c9a88949dfc15e2e)) + + +### Chores + +* **ci:** bump uv version ([e7115ed](https://github.com/ArkHQ-io/ark-python/commit/e7115edad4dd96f45c7b87f76b00792b8d096647)) +* **ci:** skip uploading artifacts on stainless-internal branches ([5ad9ccb](https://github.com/ArkHQ-io/ark-python/commit/5ad9ccb223dc029f721420b94bb87a7a7207bdb3)) +* **internal:** add request options to SSE classes ([fdc5e91](https://github.com/ArkHQ-io/ark-python/commit/fdc5e91d4774006a051e8a289bbd1b3c7eec1b8c)) +* **internal:** codegen related update ([a6cc237](https://github.com/ArkHQ-io/ark-python/commit/a6cc237ae03c83da16f8cc279dac4fe5e91e0816)) +* **internal:** make `test_proxy_environment_variables` more resilient ([709aff4](https://github.com/ArkHQ-io/ark-python/commit/709aff401224092c3e5059559951c8bc82c59866)) +* **internal:** make `test_proxy_environment_variables` more resilient to env ([df5b863](https://github.com/ArkHQ-io/ark-python/commit/df5b8639e08e14c7a64e51081f60c41d0450617b)) +* **internal:** tweak CI branches ([a50b7f8](https://github.com/ArkHQ-io/ark-python/commit/a50b7f8017b23cae995700f9a0a579bfc4b5a57b)) +* **test:** do not count install time for mock server timeout ([e9620f6](https://github.com/ArkHQ-io/ark-python/commit/e9620f619e9f8bafc0d3fdc2b070cb4e6f5454b3)) +* **tests:** bump steady to v0.19.4 ([2f78979](https://github.com/ArkHQ-io/ark-python/commit/2f78979a8b73ee72108b8e514373d36a62aafc26)) +* **tests:** bump steady to v0.19.5 ([41a53dc](https://github.com/ArkHQ-io/ark-python/commit/41a53dc50fca7e7e9a2928ad72d06ad2259837d3)) +* update mock server docs ([b4e4ce8](https://github.com/ArkHQ-io/ark-python/commit/b4e4ce8a56859a87137349e5f97ede2c8acaad25)) + + +### Refactors + +* **tests:** switch from prism to steady ([18f705f](https://github.com/ArkHQ-io/ark-python/commit/18f705f1fff9c42d14de17f0eb50eb1232ff9019)) + ## 0.18.1 (2026-02-18) Full Changelog: [v0.18.0...v0.18.1](https://github.com/ArkHQ-io/ark-python/compare/v0.18.0...v0.18.1) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1a02fdd..f676e86 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,11 +85,10 @@ $ pip install ./path-to-wheel-file.whl ## Running tests -Most tests require you to [set up a mock server](https://github.com/stoplightio/prism) against the OpenAPI spec to run the tests. +Most tests require you to [set up a mock server](https://github.com/dgellow/steady) against the OpenAPI spec to run the tests. ```sh -# you will need npm installed -$ npx prism mock path/to/your/openapi.yml +$ ./scripts/mock ``` ```sh diff --git a/pyproject.toml b/pyproject.toml index 0adad94..6e71359 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ark-email" -version = "0.18.1" +version = "0.19.0" description = "The official Python library for the ark API" dynamic = ["readme"] license = "Apache-2.0" @@ -11,7 +11,7 @@ authors = [ dependencies = [ "httpx>=0.23.0, <1", "pydantic>=1.9.0, <3", - "typing-extensions>=4.10, <5", + "typing-extensions>=4.14, <5", "anyio>=3.5.0, <5", "distro>=1.7.0, <2", "sniffio", diff --git a/scripts/mock b/scripts/mock index 0b28f6e..ab814d3 100755 --- a/scripts/mock +++ b/scripts/mock @@ -19,23 +19,34 @@ fi echo "==> Starting mock server with URL ${URL}" -# Run prism mock on the given spec +# Run steady mock on the given spec if [ "$1" == "--daemon" ]; then - npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" &> .prism.log & + # Pre-install the package so the download doesn't eat into the startup timeout + npm exec --package=@stdy/cli@0.19.5 -- steady --version - # Wait for server to come online + npm exec --package=@stdy/cli@0.19.5 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=comma --validator-query-array-format=comma --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" &> .stdy.log & + + # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" - while ! grep -q "✖ fatal\|Prism is listening" ".prism.log" ; do + attempts=0 + while ! curl --silent --fail "http://127.0.0.1:4010/_x-steady/health" >/dev/null 2>&1; do + if ! kill -0 $! 2>/dev/null; then + echo + cat .stdy.log + exit 1 + fi + attempts=$((attempts + 1)) + if [ "$attempts" -ge 300 ]; then + echo + echo "Timed out waiting for Steady server to start" + cat .stdy.log + exit 1 + fi echo -n "." sleep 0.1 done - if grep -q "✖ fatal" ".prism.log"; then - cat .prism.log - exit 1 - fi - echo else - npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" + npm exec --package=@stdy/cli@0.19.5 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=comma --validator-query-array-format=comma --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index b56970b..5105f92 100755 --- a/scripts/test +++ b/scripts/test @@ -9,8 +9,8 @@ GREEN='\033[0;32m' YELLOW='\033[0;33m' NC='\033[0m' # No Color -function prism_is_running() { - curl --silent "http://localhost:4010" >/dev/null 2>&1 +function steady_is_running() { + curl --silent "http://127.0.0.1:4010/_x-steady/health" >/dev/null 2>&1 } kill_server_on_port() { @@ -25,7 +25,7 @@ function is_overriding_api_base_url() { [ -n "$TEST_API_BASE_URL" ] } -if ! is_overriding_api_base_url && ! prism_is_running ; then +if ! is_overriding_api_base_url && ! steady_is_running ; then # When we exit this script, make sure to kill the background mock server process trap 'kill_server_on_port 4010' EXIT @@ -36,19 +36,19 @@ fi if is_overriding_api_base_url ; then echo -e "${GREEN}✔ Running tests against ${TEST_API_BASE_URL}${NC}" echo -elif ! prism_is_running ; then - echo -e "${RED}ERROR:${NC} The test suite will not run without a mock Prism server" +elif ! steady_is_running ; then + echo -e "${RED}ERROR:${NC} The test suite will not run without a mock Steady server" echo -e "running against your OpenAPI spec." echo echo -e "To run the server, pass in the path or url of your OpenAPI" - echo -e "spec to the prism command:" + echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock path/to/your.openapi.yml${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.5 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-form-array-format=comma --validator-query-array-format=comma --validator-form-object-format=brackets --validator-query-object-format=brackets${NC}" echo exit 1 else - echo -e "${GREEN}✔ Mock prism server is running with your OpenAPI spec${NC}" + echo -e "${GREEN}✔ Mock steady server is running with your OpenAPI spec${NC}" echo fi diff --git a/src/ark/_client.py b/src/ark/_client.py index 40de787..d682b1b 100644 --- a/src/ark/_client.py +++ b/src/ark/_client.py @@ -99,30 +99,103 @@ def __init__( @cached_property def emails(self) -> EmailsResource: + """Send and manage email messages. + + **Quick Reference:** + - `POST /emails` - Send a single email + - `POST /emails/batch` - Send up to 100 emails + - `GET /emails/{emailId}` - Get email status and details + - `GET /emails` - List sent emails + - `POST /emails/{emailId}/retry` - Retry failed delivery + """ from .resources.emails import EmailsResource return EmailsResource(self) @cached_property def logs(self) -> LogsResource: + """Access API request logs for debugging and monitoring. + + Every API request is logged with details including: + - Request method, path, and endpoint + - Response status code and duration + - Error details (code, message) for failed requests + - SDK information (name, version) + - Rate limit state at time of request + - Request and response bodies (for single log retrieval) + + **Retention:** Logs are retained for 90 days. + + **Body storage:** Request and response bodies are stored encrypted + and truncated at 25KB. Bodies are only returned when retrieving + a single log entry. + + **Quick Reference:** + - `GET /logs` - List API request logs with filters + - `GET /logs/{requestId}` - Get full details including request/response bodies + """ from .resources.logs import LogsResource return LogsResource(self) @cached_property def usage(self) -> UsageResource: + """Per-tenant usage analytics and bulk reporting. + + Track email sending statistics for each tenant to power billing, dashboards, and monitoring. + + **Single Tenant Usage:** + - `GET /tenants/{id}/usage` - Get usage stats for a specific tenant + - `GET /tenants/{id}/usage/timeseries` - Get time-bucketed data for charts + + **Bulk Usage:** + - `GET /usage/tenants` - Get usage for all tenants (paginated, sortable) + - `GET /usage/export` - Export usage data as CSV, JSONL, or JSON + + **Period Formats:** + - Shortcuts: `today`, `yesterday`, `this_month`, `last_month`, `last_7_days`, `last_30_days` + - Month: `2024-01` + - Date range: `2024-01-01..2024-01-15` + """ from .resources.usage import UsageResource return UsageResource(self) @cached_property def limits(self) -> LimitsResource: + """Check account rate limits and send limits. + + The limits endpoint returns current status for operational limits: + - **Rate limit:** API requests per second (currently 10/sec) + - **Send limit:** Emails per hour (default 100/hour for new accounts) + - **Billing:** Credit balance and auto-recharge configuration + + **AI Integration Note:** This endpoint is designed for AI agents and MCP servers + to understand account constraints before taking actions. Call this endpoint + first when planning batch operations to avoid hitting limits unexpectedly. + + **Quick Reference:** + - `GET /limits` - Get current rate limits and send limits + - `GET /usage` - (Deprecated) Use `/limits` instead + """ from .resources.limits import LimitsResource return LimitsResource(self) @cached_property def tenants(self) -> TenantsResource: + """Manage tenants (your customers). + + Create a tenant for each of your customers to track their email sending separately. + Store the tenant `id` in your database and use `metadata` for any custom data. + + **Quick Reference:** + - `POST /tenants` - Create a new tenant + - `GET /tenants` - List all tenants (paginated) + - `GET /tenants/{id}` - Get tenant details + - `PATCH /tenants/{id}` - Update tenant name, metadata, or status + - `DELETE /tenants/{id}` - Delete a tenant + """ from .resources.tenants import TenantsResource return TenantsResource(self) @@ -303,30 +376,103 @@ def __init__( @cached_property def emails(self) -> AsyncEmailsResource: + """Send and manage email messages. + + **Quick Reference:** + - `POST /emails` - Send a single email + - `POST /emails/batch` - Send up to 100 emails + - `GET /emails/{emailId}` - Get email status and details + - `GET /emails` - List sent emails + - `POST /emails/{emailId}/retry` - Retry failed delivery + """ from .resources.emails import AsyncEmailsResource return AsyncEmailsResource(self) @cached_property def logs(self) -> AsyncLogsResource: + """Access API request logs for debugging and monitoring. + + Every API request is logged with details including: + - Request method, path, and endpoint + - Response status code and duration + - Error details (code, message) for failed requests + - SDK information (name, version) + - Rate limit state at time of request + - Request and response bodies (for single log retrieval) + + **Retention:** Logs are retained for 90 days. + + **Body storage:** Request and response bodies are stored encrypted + and truncated at 25KB. Bodies are only returned when retrieving + a single log entry. + + **Quick Reference:** + - `GET /logs` - List API request logs with filters + - `GET /logs/{requestId}` - Get full details including request/response bodies + """ from .resources.logs import AsyncLogsResource return AsyncLogsResource(self) @cached_property def usage(self) -> AsyncUsageResource: + """Per-tenant usage analytics and bulk reporting. + + Track email sending statistics for each tenant to power billing, dashboards, and monitoring. + + **Single Tenant Usage:** + - `GET /tenants/{id}/usage` - Get usage stats for a specific tenant + - `GET /tenants/{id}/usage/timeseries` - Get time-bucketed data for charts + + **Bulk Usage:** + - `GET /usage/tenants` - Get usage for all tenants (paginated, sortable) + - `GET /usage/export` - Export usage data as CSV, JSONL, or JSON + + **Period Formats:** + - Shortcuts: `today`, `yesterday`, `this_month`, `last_month`, `last_7_days`, `last_30_days` + - Month: `2024-01` + - Date range: `2024-01-01..2024-01-15` + """ from .resources.usage import AsyncUsageResource return AsyncUsageResource(self) @cached_property def limits(self) -> AsyncLimitsResource: + """Check account rate limits and send limits. + + The limits endpoint returns current status for operational limits: + - **Rate limit:** API requests per second (currently 10/sec) + - **Send limit:** Emails per hour (default 100/hour for new accounts) + - **Billing:** Credit balance and auto-recharge configuration + + **AI Integration Note:** This endpoint is designed for AI agents and MCP servers + to understand account constraints before taking actions. Call this endpoint + first when planning batch operations to avoid hitting limits unexpectedly. + + **Quick Reference:** + - `GET /limits` - Get current rate limits and send limits + - `GET /usage` - (Deprecated) Use `/limits` instead + """ from .resources.limits import AsyncLimitsResource return AsyncLimitsResource(self) @cached_property def tenants(self) -> AsyncTenantsResource: + """Manage tenants (your customers). + + Create a tenant for each of your customers to track their email sending separately. + Store the tenant `id` in your database and use `metadata` for any custom data. + + **Quick Reference:** + - `POST /tenants` - Create a new tenant + - `GET /tenants` - List all tenants (paginated) + - `GET /tenants/{id}` - Get tenant details + - `PATCH /tenants/{id}` - Update tenant name, metadata, or status + - `DELETE /tenants/{id}` - Delete a tenant + """ from .resources.tenants import AsyncTenantsResource return AsyncTenantsResource(self) @@ -458,30 +604,103 @@ def __init__(self, client: Ark) -> None: @cached_property def emails(self) -> emails.EmailsResourceWithRawResponse: + """Send and manage email messages. + + **Quick Reference:** + - `POST /emails` - Send a single email + - `POST /emails/batch` - Send up to 100 emails + - `GET /emails/{emailId}` - Get email status and details + - `GET /emails` - List sent emails + - `POST /emails/{emailId}/retry` - Retry failed delivery + """ from .resources.emails import EmailsResourceWithRawResponse return EmailsResourceWithRawResponse(self._client.emails) @cached_property def logs(self) -> logs.LogsResourceWithRawResponse: + """Access API request logs for debugging and monitoring. + + Every API request is logged with details including: + - Request method, path, and endpoint + - Response status code and duration + - Error details (code, message) for failed requests + - SDK information (name, version) + - Rate limit state at time of request + - Request and response bodies (for single log retrieval) + + **Retention:** Logs are retained for 90 days. + + **Body storage:** Request and response bodies are stored encrypted + and truncated at 25KB. Bodies are only returned when retrieving + a single log entry. + + **Quick Reference:** + - `GET /logs` - List API request logs with filters + - `GET /logs/{requestId}` - Get full details including request/response bodies + """ from .resources.logs import LogsResourceWithRawResponse return LogsResourceWithRawResponse(self._client.logs) @cached_property def usage(self) -> usage.UsageResourceWithRawResponse: + """Per-tenant usage analytics and bulk reporting. + + Track email sending statistics for each tenant to power billing, dashboards, and monitoring. + + **Single Tenant Usage:** + - `GET /tenants/{id}/usage` - Get usage stats for a specific tenant + - `GET /tenants/{id}/usage/timeseries` - Get time-bucketed data for charts + + **Bulk Usage:** + - `GET /usage/tenants` - Get usage for all tenants (paginated, sortable) + - `GET /usage/export` - Export usage data as CSV, JSONL, or JSON + + **Period Formats:** + - Shortcuts: `today`, `yesterday`, `this_month`, `last_month`, `last_7_days`, `last_30_days` + - Month: `2024-01` + - Date range: `2024-01-01..2024-01-15` + """ from .resources.usage import UsageResourceWithRawResponse return UsageResourceWithRawResponse(self._client.usage) @cached_property def limits(self) -> limits.LimitsResourceWithRawResponse: + """Check account rate limits and send limits. + + The limits endpoint returns current status for operational limits: + - **Rate limit:** API requests per second (currently 10/sec) + - **Send limit:** Emails per hour (default 100/hour for new accounts) + - **Billing:** Credit balance and auto-recharge configuration + + **AI Integration Note:** This endpoint is designed for AI agents and MCP servers + to understand account constraints before taking actions. Call this endpoint + first when planning batch operations to avoid hitting limits unexpectedly. + + **Quick Reference:** + - `GET /limits` - Get current rate limits and send limits + - `GET /usage` - (Deprecated) Use `/limits` instead + """ from .resources.limits import LimitsResourceWithRawResponse return LimitsResourceWithRawResponse(self._client.limits) @cached_property def tenants(self) -> tenants.TenantsResourceWithRawResponse: + """Manage tenants (your customers). + + Create a tenant for each of your customers to track their email sending separately. + Store the tenant `id` in your database and use `metadata` for any custom data. + + **Quick Reference:** + - `POST /tenants` - Create a new tenant + - `GET /tenants` - List all tenants (paginated) + - `GET /tenants/{id}` - Get tenant details + - `PATCH /tenants/{id}` - Update tenant name, metadata, or status + - `DELETE /tenants/{id}` - Delete a tenant + """ from .resources.tenants import TenantsResourceWithRawResponse return TenantsResourceWithRawResponse(self._client.tenants) @@ -501,30 +720,103 @@ def __init__(self, client: AsyncArk) -> None: @cached_property def emails(self) -> emails.AsyncEmailsResourceWithRawResponse: + """Send and manage email messages. + + **Quick Reference:** + - `POST /emails` - Send a single email + - `POST /emails/batch` - Send up to 100 emails + - `GET /emails/{emailId}` - Get email status and details + - `GET /emails` - List sent emails + - `POST /emails/{emailId}/retry` - Retry failed delivery + """ from .resources.emails import AsyncEmailsResourceWithRawResponse return AsyncEmailsResourceWithRawResponse(self._client.emails) @cached_property def logs(self) -> logs.AsyncLogsResourceWithRawResponse: + """Access API request logs for debugging and monitoring. + + Every API request is logged with details including: + - Request method, path, and endpoint + - Response status code and duration + - Error details (code, message) for failed requests + - SDK information (name, version) + - Rate limit state at time of request + - Request and response bodies (for single log retrieval) + + **Retention:** Logs are retained for 90 days. + + **Body storage:** Request and response bodies are stored encrypted + and truncated at 25KB. Bodies are only returned when retrieving + a single log entry. + + **Quick Reference:** + - `GET /logs` - List API request logs with filters + - `GET /logs/{requestId}` - Get full details including request/response bodies + """ from .resources.logs import AsyncLogsResourceWithRawResponse return AsyncLogsResourceWithRawResponse(self._client.logs) @cached_property def usage(self) -> usage.AsyncUsageResourceWithRawResponse: + """Per-tenant usage analytics and bulk reporting. + + Track email sending statistics for each tenant to power billing, dashboards, and monitoring. + + **Single Tenant Usage:** + - `GET /tenants/{id}/usage` - Get usage stats for a specific tenant + - `GET /tenants/{id}/usage/timeseries` - Get time-bucketed data for charts + + **Bulk Usage:** + - `GET /usage/tenants` - Get usage for all tenants (paginated, sortable) + - `GET /usage/export` - Export usage data as CSV, JSONL, or JSON + + **Period Formats:** + - Shortcuts: `today`, `yesterday`, `this_month`, `last_month`, `last_7_days`, `last_30_days` + - Month: `2024-01` + - Date range: `2024-01-01..2024-01-15` + """ from .resources.usage import AsyncUsageResourceWithRawResponse return AsyncUsageResourceWithRawResponse(self._client.usage) @cached_property def limits(self) -> limits.AsyncLimitsResourceWithRawResponse: + """Check account rate limits and send limits. + + The limits endpoint returns current status for operational limits: + - **Rate limit:** API requests per second (currently 10/sec) + - **Send limit:** Emails per hour (default 100/hour for new accounts) + - **Billing:** Credit balance and auto-recharge configuration + + **AI Integration Note:** This endpoint is designed for AI agents and MCP servers + to understand account constraints before taking actions. Call this endpoint + first when planning batch operations to avoid hitting limits unexpectedly. + + **Quick Reference:** + - `GET /limits` - Get current rate limits and send limits + - `GET /usage` - (Deprecated) Use `/limits` instead + """ from .resources.limits import AsyncLimitsResourceWithRawResponse return AsyncLimitsResourceWithRawResponse(self._client.limits) @cached_property def tenants(self) -> tenants.AsyncTenantsResourceWithRawResponse: + """Manage tenants (your customers). + + Create a tenant for each of your customers to track their email sending separately. + Store the tenant `id` in your database and use `metadata` for any custom data. + + **Quick Reference:** + - `POST /tenants` - Create a new tenant + - `GET /tenants` - List all tenants (paginated) + - `GET /tenants/{id}` - Get tenant details + - `PATCH /tenants/{id}` - Update tenant name, metadata, or status + - `DELETE /tenants/{id}` - Delete a tenant + """ from .resources.tenants import AsyncTenantsResourceWithRawResponse return AsyncTenantsResourceWithRawResponse(self._client.tenants) @@ -544,30 +836,103 @@ def __init__(self, client: Ark) -> None: @cached_property def emails(self) -> emails.EmailsResourceWithStreamingResponse: + """Send and manage email messages. + + **Quick Reference:** + - `POST /emails` - Send a single email + - `POST /emails/batch` - Send up to 100 emails + - `GET /emails/{emailId}` - Get email status and details + - `GET /emails` - List sent emails + - `POST /emails/{emailId}/retry` - Retry failed delivery + """ from .resources.emails import EmailsResourceWithStreamingResponse return EmailsResourceWithStreamingResponse(self._client.emails) @cached_property def logs(self) -> logs.LogsResourceWithStreamingResponse: + """Access API request logs for debugging and monitoring. + + Every API request is logged with details including: + - Request method, path, and endpoint + - Response status code and duration + - Error details (code, message) for failed requests + - SDK information (name, version) + - Rate limit state at time of request + - Request and response bodies (for single log retrieval) + + **Retention:** Logs are retained for 90 days. + + **Body storage:** Request and response bodies are stored encrypted + and truncated at 25KB. Bodies are only returned when retrieving + a single log entry. + + **Quick Reference:** + - `GET /logs` - List API request logs with filters + - `GET /logs/{requestId}` - Get full details including request/response bodies + """ from .resources.logs import LogsResourceWithStreamingResponse return LogsResourceWithStreamingResponse(self._client.logs) @cached_property def usage(self) -> usage.UsageResourceWithStreamingResponse: + """Per-tenant usage analytics and bulk reporting. + + Track email sending statistics for each tenant to power billing, dashboards, and monitoring. + + **Single Tenant Usage:** + - `GET /tenants/{id}/usage` - Get usage stats for a specific tenant + - `GET /tenants/{id}/usage/timeseries` - Get time-bucketed data for charts + + **Bulk Usage:** + - `GET /usage/tenants` - Get usage for all tenants (paginated, sortable) + - `GET /usage/export` - Export usage data as CSV, JSONL, or JSON + + **Period Formats:** + - Shortcuts: `today`, `yesterday`, `this_month`, `last_month`, `last_7_days`, `last_30_days` + - Month: `2024-01` + - Date range: `2024-01-01..2024-01-15` + """ from .resources.usage import UsageResourceWithStreamingResponse return UsageResourceWithStreamingResponse(self._client.usage) @cached_property def limits(self) -> limits.LimitsResourceWithStreamingResponse: + """Check account rate limits and send limits. + + The limits endpoint returns current status for operational limits: + - **Rate limit:** API requests per second (currently 10/sec) + - **Send limit:** Emails per hour (default 100/hour for new accounts) + - **Billing:** Credit balance and auto-recharge configuration + + **AI Integration Note:** This endpoint is designed for AI agents and MCP servers + to understand account constraints before taking actions. Call this endpoint + first when planning batch operations to avoid hitting limits unexpectedly. + + **Quick Reference:** + - `GET /limits` - Get current rate limits and send limits + - `GET /usage` - (Deprecated) Use `/limits` instead + """ from .resources.limits import LimitsResourceWithStreamingResponse return LimitsResourceWithStreamingResponse(self._client.limits) @cached_property def tenants(self) -> tenants.TenantsResourceWithStreamingResponse: + """Manage tenants (your customers). + + Create a tenant for each of your customers to track their email sending separately. + Store the tenant `id` in your database and use `metadata` for any custom data. + + **Quick Reference:** + - `POST /tenants` - Create a new tenant + - `GET /tenants` - List all tenants (paginated) + - `GET /tenants/{id}` - Get tenant details + - `PATCH /tenants/{id}` - Update tenant name, metadata, or status + - `DELETE /tenants/{id}` - Delete a tenant + """ from .resources.tenants import TenantsResourceWithStreamingResponse return TenantsResourceWithStreamingResponse(self._client.tenants) @@ -587,30 +952,103 @@ def __init__(self, client: AsyncArk) -> None: @cached_property def emails(self) -> emails.AsyncEmailsResourceWithStreamingResponse: + """Send and manage email messages. + + **Quick Reference:** + - `POST /emails` - Send a single email + - `POST /emails/batch` - Send up to 100 emails + - `GET /emails/{emailId}` - Get email status and details + - `GET /emails` - List sent emails + - `POST /emails/{emailId}/retry` - Retry failed delivery + """ from .resources.emails import AsyncEmailsResourceWithStreamingResponse return AsyncEmailsResourceWithStreamingResponse(self._client.emails) @cached_property def logs(self) -> logs.AsyncLogsResourceWithStreamingResponse: + """Access API request logs for debugging and monitoring. + + Every API request is logged with details including: + - Request method, path, and endpoint + - Response status code and duration + - Error details (code, message) for failed requests + - SDK information (name, version) + - Rate limit state at time of request + - Request and response bodies (for single log retrieval) + + **Retention:** Logs are retained for 90 days. + + **Body storage:** Request and response bodies are stored encrypted + and truncated at 25KB. Bodies are only returned when retrieving + a single log entry. + + **Quick Reference:** + - `GET /logs` - List API request logs with filters + - `GET /logs/{requestId}` - Get full details including request/response bodies + """ from .resources.logs import AsyncLogsResourceWithStreamingResponse return AsyncLogsResourceWithStreamingResponse(self._client.logs) @cached_property def usage(self) -> usage.AsyncUsageResourceWithStreamingResponse: + """Per-tenant usage analytics and bulk reporting. + + Track email sending statistics for each tenant to power billing, dashboards, and monitoring. + + **Single Tenant Usage:** + - `GET /tenants/{id}/usage` - Get usage stats for a specific tenant + - `GET /tenants/{id}/usage/timeseries` - Get time-bucketed data for charts + + **Bulk Usage:** + - `GET /usage/tenants` - Get usage for all tenants (paginated, sortable) + - `GET /usage/export` - Export usage data as CSV, JSONL, or JSON + + **Period Formats:** + - Shortcuts: `today`, `yesterday`, `this_month`, `last_month`, `last_7_days`, `last_30_days` + - Month: `2024-01` + - Date range: `2024-01-01..2024-01-15` + """ from .resources.usage import AsyncUsageResourceWithStreamingResponse return AsyncUsageResourceWithStreamingResponse(self._client.usage) @cached_property def limits(self) -> limits.AsyncLimitsResourceWithStreamingResponse: + """Check account rate limits and send limits. + + The limits endpoint returns current status for operational limits: + - **Rate limit:** API requests per second (currently 10/sec) + - **Send limit:** Emails per hour (default 100/hour for new accounts) + - **Billing:** Credit balance and auto-recharge configuration + + **AI Integration Note:** This endpoint is designed for AI agents and MCP servers + to understand account constraints before taking actions. Call this endpoint + first when planning batch operations to avoid hitting limits unexpectedly. + + **Quick Reference:** + - `GET /limits` - Get current rate limits and send limits + - `GET /usage` - (Deprecated) Use `/limits` instead + """ from .resources.limits import AsyncLimitsResourceWithStreamingResponse return AsyncLimitsResourceWithStreamingResponse(self._client.limits) @cached_property def tenants(self) -> tenants.AsyncTenantsResourceWithStreamingResponse: + """Manage tenants (your customers). + + Create a tenant for each of your customers to track their email sending separately. + Store the tenant `id` in your database and use `metadata` for any custom data. + + **Quick Reference:** + - `POST /tenants` - Create a new tenant + - `GET /tenants` - List all tenants (paginated) + - `GET /tenants/{id}` - Get tenant details + - `PATCH /tenants/{id}` - Update tenant name, metadata, or status + - `DELETE /tenants/{id}` - Delete a tenant + """ from .resources.tenants import AsyncTenantsResourceWithStreamingResponse return AsyncTenantsResourceWithStreamingResponse(self._client.tenants) diff --git a/src/ark/_compat.py b/src/ark/_compat.py index 786ff42..e6690a4 100644 --- a/src/ark/_compat.py +++ b/src/ark/_compat.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Any, Union, Generic, TypeVar, Callable, cast, overload from datetime import date, datetime -from typing_extensions import Self, Literal +from typing_extensions import Self, Literal, TypedDict import pydantic from pydantic.fields import FieldInfo @@ -131,6 +131,10 @@ def model_json(model: pydantic.BaseModel, *, indent: int | None = None) -> str: return model.model_dump_json(indent=indent) +class _ModelDumpKwargs(TypedDict, total=False): + by_alias: bool + + def model_dump( model: pydantic.BaseModel, *, @@ -142,6 +146,9 @@ def model_dump( by_alias: bool | None = None, ) -> dict[str, Any]: if (not PYDANTIC_V1) or hasattr(model, "model_dump"): + kwargs: _ModelDumpKwargs = {} + if by_alias is not None: + kwargs["by_alias"] = by_alias return model.model_dump( mode=mode, exclude=exclude, @@ -149,7 +156,7 @@ def model_dump( exclude_defaults=exclude_defaults, # warnings are not supported in Pydantic v1 warnings=True if PYDANTIC_V1 else warnings, - by_alias=by_alias, + **kwargs, ) return cast( "dict[str, Any]", diff --git a/src/ark/_response.py b/src/ark/_response.py index 5fd3b90..6125f53 100644 --- a/src/ark/_response.py +++ b/src/ark/_response.py @@ -152,6 +152,7 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: ), response=self.http_response, client=cast(Any, self._client), + options=self._options, ), ) @@ -162,6 +163,7 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: cast_to=extract_stream_chunk_type(self._stream_cls), response=self.http_response, client=cast(Any, self._client), + options=self._options, ), ) @@ -175,6 +177,7 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: cast_to=cast_to, response=self.http_response, client=cast(Any, self._client), + options=self._options, ), ) diff --git a/src/ark/_streaming.py b/src/ark/_streaming.py index a271a69..cd236ca 100644 --- a/src/ark/_streaming.py +++ b/src/ark/_streaming.py @@ -4,7 +4,7 @@ import json import inspect from types import TracebackType -from typing import TYPE_CHECKING, Any, Generic, TypeVar, Iterator, AsyncIterator, cast +from typing import TYPE_CHECKING, Any, Generic, TypeVar, Iterator, Optional, AsyncIterator, cast from typing_extensions import Self, Protocol, TypeGuard, override, get_origin, runtime_checkable import httpx @@ -13,6 +13,7 @@ if TYPE_CHECKING: from ._client import Ark, AsyncArk + from ._models import FinalRequestOptions _T = TypeVar("_T") @@ -22,7 +23,7 @@ class Stream(Generic[_T]): """Provides the core interface to iterate over a synchronous stream response.""" response: httpx.Response - + _options: Optional[FinalRequestOptions] = None _decoder: SSEBytesDecoder def __init__( @@ -31,10 +32,12 @@ def __init__( cast_to: type[_T], response: httpx.Response, client: Ark, + options: Optional[FinalRequestOptions] = None, ) -> None: self.response = response self._cast_to = cast_to self._client = client + self._options = options self._decoder = client._make_sse_decoder() self._iterator = self.__stream__() @@ -85,7 +88,7 @@ class AsyncStream(Generic[_T]): """Provides the core interface to iterate over an asynchronous stream response.""" response: httpx.Response - + _options: Optional[FinalRequestOptions] = None _decoder: SSEDecoder | SSEBytesDecoder def __init__( @@ -94,10 +97,12 @@ def __init__( cast_to: type[_T], response: httpx.Response, client: AsyncArk, + options: Optional[FinalRequestOptions] = None, ) -> None: self.response = response self._cast_to = cast_to self._client = client + self._options = options self._decoder = client._make_sse_decoder() self._iterator = self.__stream__() diff --git a/src/ark/_utils/__init__.py b/src/ark/_utils/__init__.py index dc64e29..10cb66d 100644 --- a/src/ark/_utils/__init__.py +++ b/src/ark/_utils/__init__.py @@ -1,3 +1,4 @@ +from ._path import path_template as path_template from ._sync import asyncify as asyncify from ._proxy import LazyProxy as LazyProxy from ._utils import ( diff --git a/src/ark/_utils/_path.py b/src/ark/_utils/_path.py new file mode 100644 index 0000000..4d6e1e4 --- /dev/null +++ b/src/ark/_utils/_path.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import re +from typing import ( + Any, + Mapping, + Callable, +) +from urllib.parse import quote + +# Matches '.' or '..' where each dot is either literal or percent-encoded (%2e / %2E). +_DOT_SEGMENT_RE = re.compile(r"^(?:\.|%2[eE]){1,2}$") + +_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}") + + +def _quote_path_segment_part(value: str) -> str: + """Percent-encode `value` for use in a URI path segment. + + Considers characters not in `pchar` set from RFC 3986 §3.3 to be unsafe. + https://datatracker.ietf.org/doc/html/rfc3986#section-3.3 + """ + # quote() already treats unreserved characters (letters, digits, and -._~) + # as safe, so we only need to add sub-delims, ':', and '@'. + # Notably, unlike the default `safe` for quote(), / is unsafe and must be quoted. + return quote(value, safe="!$&'()*+,;=:@") + + +def _quote_query_part(value: str) -> str: + """Percent-encode `value` for use in a URI query string. + + Considers &, = and characters not in `query` set from RFC 3986 §3.4 to be unsafe. + https://datatracker.ietf.org/doc/html/rfc3986#section-3.4 + """ + return quote(value, safe="!$'()*+,;:@/?") + + +def _quote_fragment_part(value: str) -> str: + """Percent-encode `value` for use in a URI fragment. + + Considers characters not in `fragment` set from RFC 3986 §3.5 to be unsafe. + https://datatracker.ietf.org/doc/html/rfc3986#section-3.5 + """ + return quote(value, safe="!$&'()*+,;=:@/?") + + +def _interpolate( + template: str, + values: Mapping[str, Any], + quoter: Callable[[str], str], +) -> str: + """Replace {name} placeholders in `template`, quoting each value with `quoter`. + + Placeholder names are looked up in `values`. + + Raises: + KeyError: If a placeholder is not found in `values`. + """ + # re.split with a capturing group returns alternating + # [text, name, text, name, ..., text] elements. + parts = _PLACEHOLDER_RE.split(template) + + for i in range(1, len(parts), 2): + name = parts[i] + if name not in values: + raise KeyError(f"a value for placeholder {{{name}}} was not provided") + val = values[name] + if val is None: + parts[i] = "null" + elif isinstance(val, bool): + parts[i] = "true" if val else "false" + else: + parts[i] = quoter(str(values[name])) + + return "".join(parts) + + +def path_template(template: str, /, **kwargs: Any) -> str: + """Interpolate {name} placeholders in `template` from keyword arguments. + + Args: + template: The template string containing {name} placeholders. + **kwargs: Keyword arguments to interpolate into the template. + + Returns: + The template with placeholders interpolated and percent-encoded. + + Safe characters for percent-encoding are dependent on the URI component. + Placeholders in path and fragment portions are percent-encoded where the `segment` + and `fragment` sets from RFC 3986 respectively are considered safe. + Placeholders in the query portion are percent-encoded where the `query` set from + RFC 3986 §3.3 is considered safe except for = and & characters. + + Raises: + KeyError: If a placeholder is not found in `kwargs`. + ValueError: If resulting path contains /./ or /../ segments (including percent-encoded dot-segments). + """ + # Split the template into path, query, and fragment portions. + fragment_template: str | None = None + query_template: str | None = None + + rest = template + if "#" in rest: + rest, fragment_template = rest.split("#", 1) + if "?" in rest: + rest, query_template = rest.split("?", 1) + path_template = rest + + # Interpolate each portion with the appropriate quoting rules. + path_result = _interpolate(path_template, kwargs, _quote_path_segment_part) + + # Reject dot-segments (. and ..) in the final assembled path. The check + # runs after interpolation so that adjacent placeholders or a mix of static + # text and placeholders that together form a dot-segment are caught. + # Also reject percent-encoded dot-segments to protect against incorrectly + # implemented normalization in servers/proxies. + for segment in path_result.split("/"): + if _DOT_SEGMENT_RE.match(segment): + raise ValueError(f"Constructed path {path_result!r} contains dot-segment {segment!r} which is not allowed") + + result = path_result + if query_template is not None: + result += "?" + _interpolate(query_template, kwargs, _quote_query_part) + if fragment_template is not None: + result += "#" + _interpolate(fragment_template, kwargs, _quote_fragment_part) + + return result diff --git a/src/ark/_version.py b/src/ark/_version.py index 39f8f2d..a626377 100644 --- a/src/ark/_version.py +++ b/src/ark/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "ark" -__version__ = "0.18.1" # x-release-please-version +__version__ = "0.19.0" # x-release-please-version diff --git a/src/ark/resources/emails.py b/src/ark/resources/emails.py index 77631ce..a9473fa 100644 --- a/src/ark/resources/emails.py +++ b/src/ark/resources/emails.py @@ -15,7 +15,7 @@ email_send_batch_params, ) from .._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given -from .._utils import maybe_transform, strip_not_given, async_maybe_transform +from .._utils import path_template, maybe_transform, strip_not_given, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import ( @@ -38,6 +38,16 @@ class EmailsResource(SyncAPIResource): + """Send and manage email messages. + + **Quick Reference:** + - `POST /emails` - Send a single email + - `POST /emails/batch` - Send up to 100 emails + - `GET /emails/{emailId}` - Get email status and details + - `GET /emails` - List sent emails + - `POST /emails/{emailId}/retry` - Retry failed delivery + """ + @cached_property def with_raw_response(self) -> EmailsResourceWithRawResponse: """ @@ -99,7 +109,7 @@ def retrieve( if not email_id: raise ValueError(f"Expected a non-empty value for `email_id` but received {email_id!r}") return self._get( - f"/emails/{email_id}", + path_template("/emails/{email_id}", email_id=email_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -259,7 +269,7 @@ def retrieve_deliveries( if not email_id: raise ValueError(f"Expected a non-empty value for `email_id` but received {email_id!r}") return self._get( - f"/emails/{email_id}/deliveries", + path_template("/emails/{email_id}/deliveries", email_id=email_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -296,7 +306,7 @@ def retry( if not email_id: raise ValueError(f"Expected a non-empty value for `email_id` but received {email_id!r}") return self._post( - f"/emails/{email_id}/retry", + path_template("/emails/{email_id}/retry", email_id=email_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -317,6 +327,7 @@ def send( metadata: Optional[Dict[str, str]] | Omit = omit, reply_to: Optional[str] | Omit = omit, tag: Optional[str] | Omit = omit, + tenant_id: Optional[str] | Omit = omit, text: Optional[str] | Omit = omit, idempotency_key: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. @@ -396,6 +407,14 @@ def send( tag: Tag for categorization and filtering (accepts null) + tenant_id: The tenant ID to send this email from. Determines which tenant's configuration + (domains, webhooks, tracking) is used. + + - If your API key is scoped to a specific tenant, this must match that tenant or + be omitted. + - If your API key is org-level, specify the tenant to send from. + - If omitted, the organization's default tenant is used. + text: Plain text body (accepts null, auto-generated from HTML if not provided). Maximum 5MB (5,242,880 characters). @@ -423,6 +442,7 @@ def send( "metadata": metadata, "reply_to": reply_to, "tag": tag, + "tenant_id": tenant_id, "text": text, }, email_send_params.EmailSendParams, @@ -438,6 +458,7 @@ def send_batch( *, emails: Iterable[email_send_batch_params.Email], from_: str, + tenant_id: Optional[str] | Omit = omit, idempotency_key: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -459,6 +480,14 @@ def send_batch( Args: from_: Sender email for all messages + tenant_id: The tenant ID to send this batch from. Determines which tenant's configuration + (domains, webhooks, tracking) is used. + + - If your API key is scoped to a specific tenant, this must match that tenant or + be omitted. + - If your API key is org-level, specify the tenant to send from. + - If omitted, the organization's default tenant is used. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -474,6 +503,7 @@ def send_batch( { "emails": emails, "from_": from_, + "tenant_id": tenant_id, }, email_send_batch_params.EmailSendBatchParams, ), @@ -490,6 +520,7 @@ def send_raw( raw_message: str, to: SequenceNotStr[str], bounce: Optional[bool] | Omit = omit, + tenant_id: Optional[str] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -527,6 +558,14 @@ def send_raw( bounce: Whether this is a bounce message (accepts null) + tenant_id: The tenant ID to send this email from. Determines which tenant's configuration + (domains, webhooks, tracking) is used. + + - If your API key is scoped to a specific tenant, this must match that tenant or + be omitted. + - If your API key is org-level, specify the tenant to send from. + - If omitted, the organization's default tenant is used. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -543,6 +582,7 @@ def send_raw( "raw_message": raw_message, "to": to, "bounce": bounce, + "tenant_id": tenant_id, }, email_send_raw_params.EmailSendRawParams, ), @@ -554,6 +594,16 @@ def send_raw( class AsyncEmailsResource(AsyncAPIResource): + """Send and manage email messages. + + **Quick Reference:** + - `POST /emails` - Send a single email + - `POST /emails/batch` - Send up to 100 emails + - `GET /emails/{emailId}` - Get email status and details + - `GET /emails` - List sent emails + - `POST /emails/{emailId}/retry` - Retry failed delivery + """ + @cached_property def with_raw_response(self) -> AsyncEmailsResourceWithRawResponse: """ @@ -615,7 +665,7 @@ async def retrieve( if not email_id: raise ValueError(f"Expected a non-empty value for `email_id` but received {email_id!r}") return await self._get( - f"/emails/{email_id}", + path_template("/emails/{email_id}", email_id=email_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -775,7 +825,7 @@ async def retrieve_deliveries( if not email_id: raise ValueError(f"Expected a non-empty value for `email_id` but received {email_id!r}") return await self._get( - f"/emails/{email_id}/deliveries", + path_template("/emails/{email_id}/deliveries", email_id=email_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -812,7 +862,7 @@ async def retry( if not email_id: raise ValueError(f"Expected a non-empty value for `email_id` but received {email_id!r}") return await self._post( - f"/emails/{email_id}/retry", + path_template("/emails/{email_id}/retry", email_id=email_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -833,6 +883,7 @@ async def send( metadata: Optional[Dict[str, str]] | Omit = omit, reply_to: Optional[str] | Omit = omit, tag: Optional[str] | Omit = omit, + tenant_id: Optional[str] | Omit = omit, text: Optional[str] | Omit = omit, idempotency_key: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. @@ -912,6 +963,14 @@ async def send( tag: Tag for categorization and filtering (accepts null) + tenant_id: The tenant ID to send this email from. Determines which tenant's configuration + (domains, webhooks, tracking) is used. + + - If your API key is scoped to a specific tenant, this must match that tenant or + be omitted. + - If your API key is org-level, specify the tenant to send from. + - If omitted, the organization's default tenant is used. + text: Plain text body (accepts null, auto-generated from HTML if not provided). Maximum 5MB (5,242,880 characters). @@ -939,6 +998,7 @@ async def send( "metadata": metadata, "reply_to": reply_to, "tag": tag, + "tenant_id": tenant_id, "text": text, }, email_send_params.EmailSendParams, @@ -954,6 +1014,7 @@ async def send_batch( *, emails: Iterable[email_send_batch_params.Email], from_: str, + tenant_id: Optional[str] | Omit = omit, idempotency_key: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -975,6 +1036,14 @@ async def send_batch( Args: from_: Sender email for all messages + tenant_id: The tenant ID to send this batch from. Determines which tenant's configuration + (domains, webhooks, tracking) is used. + + - If your API key is scoped to a specific tenant, this must match that tenant or + be omitted. + - If your API key is org-level, specify the tenant to send from. + - If omitted, the organization's default tenant is used. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -990,6 +1059,7 @@ async def send_batch( { "emails": emails, "from_": from_, + "tenant_id": tenant_id, }, email_send_batch_params.EmailSendBatchParams, ), @@ -1006,6 +1076,7 @@ async def send_raw( raw_message: str, to: SequenceNotStr[str], bounce: Optional[bool] | Omit = omit, + tenant_id: Optional[str] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -1043,6 +1114,14 @@ async def send_raw( bounce: Whether this is a bounce message (accepts null) + tenant_id: The tenant ID to send this email from. Determines which tenant's configuration + (domains, webhooks, tracking) is used. + + - If your API key is scoped to a specific tenant, this must match that tenant or + be omitted. + - If your API key is org-level, specify the tenant to send from. + - If omitted, the organization's default tenant is used. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -1059,6 +1138,7 @@ async def send_raw( "raw_message": raw_message, "to": to, "bounce": bounce, + "tenant_id": tenant_id, }, email_send_raw_params.EmailSendRawParams, ), diff --git a/src/ark/resources/limits.py b/src/ark/resources/limits.py index 8dd9db6..19a468b 100644 --- a/src/ark/resources/limits.py +++ b/src/ark/resources/limits.py @@ -20,6 +20,22 @@ class LimitsResource(SyncAPIResource): + """Check account rate limits and send limits. + + The limits endpoint returns current status for operational limits: + - **Rate limit:** API requests per second (currently 10/sec) + - **Send limit:** Emails per hour (default 100/hour for new accounts) + - **Billing:** Credit balance and auto-recharge configuration + + **AI Integration Note:** This endpoint is designed for AI agents and MCP servers + to understand account constraints before taking actions. Call this endpoint + first when planning batch operations to avoid hitting limits unexpectedly. + + **Quick Reference:** + - `GET /limits` - Get current rate limits and send limits + - `GET /usage` - (Deprecated) Use `/limits` instead + """ + @cached_property def with_raw_response(self) -> LimitsResourceWithRawResponse: """ @@ -78,6 +94,22 @@ def retrieve( class AsyncLimitsResource(AsyncAPIResource): + """Check account rate limits and send limits. + + The limits endpoint returns current status for operational limits: + - **Rate limit:** API requests per second (currently 10/sec) + - **Send limit:** Emails per hour (default 100/hour for new accounts) + - **Billing:** Credit balance and auto-recharge configuration + + **AI Integration Note:** This endpoint is designed for AI agents and MCP servers + to understand account constraints before taking actions. Call this endpoint + first when planning batch operations to avoid hitting limits unexpectedly. + + **Quick Reference:** + - `GET /limits` - Get current rate limits and send limits + - `GET /usage` - (Deprecated) Use `/limits` instead + """ + @cached_property def with_raw_response(self) -> AsyncLimitsResourceWithRawResponse: """ diff --git a/src/ark/resources/logs.py b/src/ark/resources/logs.py index 573538d..ea1ac14 100644 --- a/src/ark/resources/logs.py +++ b/src/ark/resources/logs.py @@ -10,7 +10,7 @@ from ..types import log_list_params from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given -from .._utils import maybe_transform +from .._utils import path_template, maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import ( @@ -28,6 +28,27 @@ class LogsResource(SyncAPIResource): + """Access API request logs for debugging and monitoring. + + Every API request is logged with details including: + - Request method, path, and endpoint + - Response status code and duration + - Error details (code, message) for failed requests + - SDK information (name, version) + - Rate limit state at time of request + - Request and response bodies (for single log retrieval) + + **Retention:** Logs are retained for 90 days. + + **Body storage:** Request and response bodies are stored encrypted + and truncated at 25KB. Bodies are only returned when retrieving + a single log entry. + + **Quick Reference:** + - `GET /logs` - List API request logs with filters + - `GET /logs/{requestId}` - Get full details including request/response bodies + """ + @cached_property def with_raw_response(self) -> LogsResourceWithRawResponse: """ @@ -88,7 +109,7 @@ def retrieve( if not request_id: raise ValueError(f"Expected a non-empty value for `request_id` but received {request_id!r}") return self._get( - f"/logs/{request_id}", + path_template("/logs/{request_id}", request_id=request_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -198,6 +219,27 @@ def list( class AsyncLogsResource(AsyncAPIResource): + """Access API request logs for debugging and monitoring. + + Every API request is logged with details including: + - Request method, path, and endpoint + - Response status code and duration + - Error details (code, message) for failed requests + - SDK information (name, version) + - Rate limit state at time of request + - Request and response bodies (for single log retrieval) + + **Retention:** Logs are retained for 90 days. + + **Body storage:** Request and response bodies are stored encrypted + and truncated at 25KB. Bodies are only returned when retrieving + a single log entry. + + **Quick Reference:** + - `GET /logs` - List API request logs with filters + - `GET /logs/{requestId}` - Get full details including request/response bodies + """ + @cached_property def with_raw_response(self) -> AsyncLogsResourceWithRawResponse: """ @@ -258,7 +300,7 @@ async def retrieve( if not request_id: raise ValueError(f"Expected a non-empty value for `request_id` but received {request_id!r}") return await self._get( - f"/logs/{request_id}", + path_template("/logs/{request_id}", request_id=request_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), diff --git a/src/ark/resources/platform/webhooks.py b/src/ark/resources/platform/webhooks.py index 7a30429..60dff83 100644 --- a/src/ark/resources/platform/webhooks.py +++ b/src/ark/resources/platform/webhooks.py @@ -8,7 +8,7 @@ import httpx from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given -from ..._utils import maybe_transform, async_maybe_transform +from ..._utils import path_template, maybe_transform, async_maybe_transform from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import ( @@ -165,7 +165,7 @@ def retrieve( if not webhook_id: raise ValueError(f"Expected a non-empty value for `webhook_id` but received {webhook_id!r}") return self._get( - f"/platform/webhooks/{webhook_id}", + path_template("/platform/webhooks/{webhook_id}", webhook_id=webhook_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -229,7 +229,7 @@ def update( if not webhook_id: raise ValueError(f"Expected a non-empty value for `webhook_id` but received {webhook_id!r}") return self._patch( - f"/platform/webhooks/{webhook_id}", + path_template("/platform/webhooks/{webhook_id}", webhook_id=webhook_id), body=maybe_transform( { "enabled": enabled, @@ -298,7 +298,7 @@ def delete( if not webhook_id: raise ValueError(f"Expected a non-empty value for `webhook_id` but received {webhook_id!r}") return self._delete( - f"/platform/webhooks/{webhook_id}", + path_template("/platform/webhooks/{webhook_id}", webhook_id=webhook_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -425,7 +425,7 @@ def replay_delivery( if not delivery_id: raise ValueError(f"Expected a non-empty value for `delivery_id` but received {delivery_id!r}") return self._post( - f"/platform/webhooks/deliveries/{delivery_id}/replay", + path_template("/platform/webhooks/deliveries/{delivery_id}/replay", delivery_id=delivery_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -460,7 +460,7 @@ def retrieve_delivery( if not delivery_id: raise ValueError(f"Expected a non-empty value for `delivery_id` but received {delivery_id!r}") return self._get( - f"/platform/webhooks/deliveries/{delivery_id}", + path_template("/platform/webhooks/deliveries/{delivery_id}", delivery_id=delivery_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -514,7 +514,7 @@ def test( if not webhook_id: raise ValueError(f"Expected a non-empty value for `webhook_id` but received {webhook_id!r}") return self._post( - f"/platform/webhooks/{webhook_id}/test", + path_template("/platform/webhooks/{webhook_id}/test", webhook_id=webhook_id), body=maybe_transform({"event": event}, webhook_test_params.WebhookTestParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout @@ -650,7 +650,7 @@ async def retrieve( if not webhook_id: raise ValueError(f"Expected a non-empty value for `webhook_id` but received {webhook_id!r}") return await self._get( - f"/platform/webhooks/{webhook_id}", + path_template("/platform/webhooks/{webhook_id}", webhook_id=webhook_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -714,7 +714,7 @@ async def update( if not webhook_id: raise ValueError(f"Expected a non-empty value for `webhook_id` but received {webhook_id!r}") return await self._patch( - f"/platform/webhooks/{webhook_id}", + path_template("/platform/webhooks/{webhook_id}", webhook_id=webhook_id), body=await async_maybe_transform( { "enabled": enabled, @@ -783,7 +783,7 @@ async def delete( if not webhook_id: raise ValueError(f"Expected a non-empty value for `webhook_id` but received {webhook_id!r}") return await self._delete( - f"/platform/webhooks/{webhook_id}", + path_template("/platform/webhooks/{webhook_id}", webhook_id=webhook_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -910,7 +910,7 @@ async def replay_delivery( if not delivery_id: raise ValueError(f"Expected a non-empty value for `delivery_id` but received {delivery_id!r}") return await self._post( - f"/platform/webhooks/deliveries/{delivery_id}/replay", + path_template("/platform/webhooks/deliveries/{delivery_id}/replay", delivery_id=delivery_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -945,7 +945,7 @@ async def retrieve_delivery( if not delivery_id: raise ValueError(f"Expected a non-empty value for `delivery_id` but received {delivery_id!r}") return await self._get( - f"/platform/webhooks/deliveries/{delivery_id}", + path_template("/platform/webhooks/deliveries/{delivery_id}", delivery_id=delivery_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -999,7 +999,7 @@ async def test( if not webhook_id: raise ValueError(f"Expected a non-empty value for `webhook_id` but received {webhook_id!r}") return await self._post( - f"/platform/webhooks/{webhook_id}/test", + path_template("/platform/webhooks/{webhook_id}/test", webhook_id=webhook_id), body=await async_maybe_transform({"event": event}, webhook_test_params.WebhookTestParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout diff --git a/src/ark/resources/tenants/credentials.py b/src/ark/resources/tenants/credentials.py index 2affa6a..c0c0c9b 100644 --- a/src/ark/resources/tenants/credentials.py +++ b/src/ark/resources/tenants/credentials.py @@ -7,7 +7,7 @@ import httpx from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given -from ..._utils import maybe_transform, async_maybe_transform +from ..._utils import path_template, maybe_transform, async_maybe_transform from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import ( @@ -100,7 +100,7 @@ def create( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return self._post( - f"/tenants/{tenant_id}/credentials", + path_template("/tenants/{tenant_id}/credentials", tenant_id=tenant_id), body=maybe_transform( { "name": name, @@ -148,7 +148,9 @@ def retrieve( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return self._get( - f"/tenants/{tenant_id}/credentials/{credential_id}", + path_template( + "/tenants/{tenant_id}/credentials/{credential_id}", tenant_id=tenant_id, credential_id=credential_id + ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -200,7 +202,9 @@ def update( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return self._patch( - f"/tenants/{tenant_id}/credentials/{credential_id}", + path_template( + "/tenants/{tenant_id}/credentials/{credential_id}", tenant_id=tenant_id, credential_id=credential_id + ), body=maybe_transform( { "hold": hold, @@ -254,7 +258,7 @@ def list( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return self._get_api_list( - f"/tenants/{tenant_id}/credentials", + path_template("/tenants/{tenant_id}/credentials", tenant_id=tenant_id), page=SyncPageNumberPagination[CredentialListResponse], options=make_request_options( extra_headers=extra_headers, @@ -305,7 +309,9 @@ def delete( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return self._delete( - f"/tenants/{tenant_id}/credentials/{credential_id}", + path_template( + "/tenants/{tenant_id}/credentials/{credential_id}", tenant_id=tenant_id, credential_id=credential_id + ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -380,7 +386,7 @@ async def create( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return await self._post( - f"/tenants/{tenant_id}/credentials", + path_template("/tenants/{tenant_id}/credentials", tenant_id=tenant_id), body=await async_maybe_transform( { "name": name, @@ -428,7 +434,9 @@ async def retrieve( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return await self._get( - f"/tenants/{tenant_id}/credentials/{credential_id}", + path_template( + "/tenants/{tenant_id}/credentials/{credential_id}", tenant_id=tenant_id, credential_id=credential_id + ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -482,7 +490,9 @@ async def update( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return await self._patch( - f"/tenants/{tenant_id}/credentials/{credential_id}", + path_template( + "/tenants/{tenant_id}/credentials/{credential_id}", tenant_id=tenant_id, credential_id=credential_id + ), body=await async_maybe_transform( { "hold": hold, @@ -536,7 +546,7 @@ def list( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return self._get_api_list( - f"/tenants/{tenant_id}/credentials", + path_template("/tenants/{tenant_id}/credentials", tenant_id=tenant_id), page=AsyncPageNumberPagination[CredentialListResponse], options=make_request_options( extra_headers=extra_headers, @@ -587,7 +597,9 @@ async def delete( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return await self._delete( - f"/tenants/{tenant_id}/credentials/{credential_id}", + path_template( + "/tenants/{tenant_id}/credentials/{credential_id}", tenant_id=tenant_id, credential_id=credential_id + ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), diff --git a/src/ark/resources/tenants/domains.py b/src/ark/resources/tenants/domains.py index e560458..b590c85 100644 --- a/src/ark/resources/tenants/domains.py +++ b/src/ark/resources/tenants/domains.py @@ -5,7 +5,7 @@ import httpx from ..._types import Body, Query, Headers, NotGiven, not_given -from ..._utils import maybe_transform, async_maybe_transform +from ..._utils import path_template, maybe_transform, async_maybe_transform from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import ( @@ -26,6 +26,20 @@ class DomainsResource(SyncAPIResource): + """Manage sending domains. + + Before you can send emails, you need to: + 1. Add a domain + 2. Configure DNS records (SPF, DKIM, Return Path) + 3. Verify the domain + + **Quick Reference:** + - `POST /domains` - Add a new domain + - `GET /domains` - List all domains + - `POST /domains/{id}/verify` - Check DNS and verify domain + - `DELETE /domains/{id}` - Remove a domain + """ + @cached_property def with_raw_response(self) -> DomainsResourceWithRawResponse: """ @@ -87,7 +101,7 @@ def create( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return self._post( - f"/tenants/{tenant_id}/domains", + path_template("/tenants/{tenant_id}/domains", tenant_id=tenant_id), body=maybe_transform({"name": name}, domain_create_params.DomainCreateParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout @@ -124,7 +138,7 @@ def retrieve( if not domain_id: raise ValueError(f"Expected a non-empty value for `domain_id` but received {domain_id!r}") return self._get( - f"/tenants/{tenant_id}/domains/{domain_id}", + path_template("/tenants/{tenant_id}/domains/{domain_id}", tenant_id=tenant_id, domain_id=domain_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -157,7 +171,7 @@ def list( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return self._get( - f"/tenants/{tenant_id}/domains", + path_template("/tenants/{tenant_id}/domains", tenant_id=tenant_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -197,7 +211,7 @@ def delete( if not domain_id: raise ValueError(f"Expected a non-empty value for `domain_id` but received {domain_id!r}") return self._delete( - f"/tenants/{tenant_id}/domains/{domain_id}", + path_template("/tenants/{tenant_id}/domains/{domain_id}", tenant_id=tenant_id, domain_id=domain_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -237,7 +251,7 @@ def verify( if not domain_id: raise ValueError(f"Expected a non-empty value for `domain_id` but received {domain_id!r}") return self._post( - f"/tenants/{tenant_id}/domains/{domain_id}/verify", + path_template("/tenants/{tenant_id}/domains/{domain_id}/verify", tenant_id=tenant_id, domain_id=domain_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -246,6 +260,20 @@ def verify( class AsyncDomainsResource(AsyncAPIResource): + """Manage sending domains. + + Before you can send emails, you need to: + 1. Add a domain + 2. Configure DNS records (SPF, DKIM, Return Path) + 3. Verify the domain + + **Quick Reference:** + - `POST /domains` - Add a new domain + - `GET /domains` - List all domains + - `POST /domains/{id}/verify` - Check DNS and verify domain + - `DELETE /domains/{id}` - Remove a domain + """ + @cached_property def with_raw_response(self) -> AsyncDomainsResourceWithRawResponse: """ @@ -307,7 +335,7 @@ async def create( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return await self._post( - f"/tenants/{tenant_id}/domains", + path_template("/tenants/{tenant_id}/domains", tenant_id=tenant_id), body=await async_maybe_transform({"name": name}, domain_create_params.DomainCreateParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout @@ -344,7 +372,7 @@ async def retrieve( if not domain_id: raise ValueError(f"Expected a non-empty value for `domain_id` but received {domain_id!r}") return await self._get( - f"/tenants/{tenant_id}/domains/{domain_id}", + path_template("/tenants/{tenant_id}/domains/{domain_id}", tenant_id=tenant_id, domain_id=domain_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -377,7 +405,7 @@ async def list( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return await self._get( - f"/tenants/{tenant_id}/domains", + path_template("/tenants/{tenant_id}/domains", tenant_id=tenant_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -417,7 +445,7 @@ async def delete( if not domain_id: raise ValueError(f"Expected a non-empty value for `domain_id` but received {domain_id!r}") return await self._delete( - f"/tenants/{tenant_id}/domains/{domain_id}", + path_template("/tenants/{tenant_id}/domains/{domain_id}", tenant_id=tenant_id, domain_id=domain_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -457,7 +485,7 @@ async def verify( if not domain_id: raise ValueError(f"Expected a non-empty value for `domain_id` but received {domain_id!r}") return await self._post( - f"/tenants/{tenant_id}/domains/{domain_id}/verify", + path_template("/tenants/{tenant_id}/domains/{domain_id}/verify", tenant_id=tenant_id, domain_id=domain_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), diff --git a/src/ark/resources/tenants/suppressions.py b/src/ark/resources/tenants/suppressions.py index 070b7e1..d6634b9 100644 --- a/src/ark/resources/tenants/suppressions.py +++ b/src/ark/resources/tenants/suppressions.py @@ -7,7 +7,7 @@ import httpx from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given -from ..._utils import maybe_transform, async_maybe_transform +from ..._utils import path_template, maybe_transform, async_maybe_transform from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import ( @@ -28,6 +28,18 @@ class SuppressionsResource(SyncAPIResource): + """Manage the suppression list. + + Suppressed email addresses will not receive any emails. Addresses are + automatically suppressed when they hard bounce or file spam complaints. + + **Quick Reference:** + - `GET /suppressions` - List suppressed addresses + - `POST /suppressions` - Add to suppression list + - `DELETE /suppressions/{email}` - Remove from suppression list + - `GET /suppressions/{email}` - Check if address is suppressed + """ + @cached_property def with_raw_response(self) -> SuppressionsResourceWithRawResponse: """ @@ -81,7 +93,7 @@ def create( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return self._post( - f"/tenants/{tenant_id}/suppressions", + path_template("/tenants/{tenant_id}/suppressions", tenant_id=tenant_id), body=maybe_transform( { "address": address, @@ -124,7 +136,7 @@ def retrieve( if not email: raise ValueError(f"Expected a non-empty value for `email` but received {email!r}") return self._get( - f"/tenants/{tenant_id}/suppressions/{email}", + path_template("/tenants/{tenant_id}/suppressions/{email}", tenant_id=tenant_id, email=email), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -161,7 +173,7 @@ def list( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return self._get_api_list( - f"/tenants/{tenant_id}/suppressions", + path_template("/tenants/{tenant_id}/suppressions", tenant_id=tenant_id), page=SyncPageNumberPagination[SuppressionListResponse], options=make_request_options( extra_headers=extra_headers, @@ -210,7 +222,7 @@ def delete( if not email: raise ValueError(f"Expected a non-empty value for `email` but received {email!r}") return self._delete( - f"/tenants/{tenant_id}/suppressions/{email}", + path_template("/tenants/{tenant_id}/suppressions/{email}", tenant_id=tenant_id, email=email), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -219,6 +231,18 @@ def delete( class AsyncSuppressionsResource(AsyncAPIResource): + """Manage the suppression list. + + Suppressed email addresses will not receive any emails. Addresses are + automatically suppressed when they hard bounce or file spam complaints. + + **Quick Reference:** + - `GET /suppressions` - List suppressed addresses + - `POST /suppressions` - Add to suppression list + - `DELETE /suppressions/{email}` - Remove from suppression list + - `GET /suppressions/{email}` - Check if address is suppressed + """ + @cached_property def with_raw_response(self) -> AsyncSuppressionsResourceWithRawResponse: """ @@ -272,7 +296,7 @@ async def create( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return await self._post( - f"/tenants/{tenant_id}/suppressions", + path_template("/tenants/{tenant_id}/suppressions", tenant_id=tenant_id), body=await async_maybe_transform( { "address": address, @@ -315,7 +339,7 @@ async def retrieve( if not email: raise ValueError(f"Expected a non-empty value for `email` but received {email!r}") return await self._get( - f"/tenants/{tenant_id}/suppressions/{email}", + path_template("/tenants/{tenant_id}/suppressions/{email}", tenant_id=tenant_id, email=email), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -352,7 +376,7 @@ def list( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return self._get_api_list( - f"/tenants/{tenant_id}/suppressions", + path_template("/tenants/{tenant_id}/suppressions", tenant_id=tenant_id), page=AsyncPageNumberPagination[SuppressionListResponse], options=make_request_options( extra_headers=extra_headers, @@ -401,7 +425,7 @@ async def delete( if not email: raise ValueError(f"Expected a non-empty value for `email` but received {email!r}") return await self._delete( - f"/tenants/{tenant_id}/suppressions/{email}", + path_template("/tenants/{tenant_id}/suppressions/{email}", tenant_id=tenant_id, email=email), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), diff --git a/src/ark/resources/tenants/tenants.py b/src/ark/resources/tenants/tenants.py index 684cf1a..c3a8de9 100644 --- a/src/ark/resources/tenants/tenants.py +++ b/src/ark/resources/tenants/tenants.py @@ -25,7 +25,7 @@ AsyncDomainsResourceWithStreamingResponse, ) from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given -from ..._utils import maybe_transform, async_maybe_transform +from ..._utils import path_template, maybe_transform, async_maybe_transform from .tracking import ( TrackingResource, AsyncTrackingResource, @@ -78,28 +78,155 @@ class TenantsResource(SyncAPIResource): + """Manage tenants (your customers). + + Create a tenant for each of your customers to track their email sending separately. + Store the tenant `id` in your database and use `metadata` for any custom data. + + **Quick Reference:** + - `POST /tenants` - Create a new tenant + - `GET /tenants` - List all tenants (paginated) + - `GET /tenants/{id}` - Get tenant details + - `PATCH /tenants/{id}` - Update tenant name, metadata, or status + - `DELETE /tenants/{id}` - Delete a tenant + """ + @cached_property def credentials(self) -> CredentialsResource: return CredentialsResource(self._client) @cached_property def domains(self) -> DomainsResource: + """Manage sending domains. + + Before you can send emails, you need to: + 1. Add a domain + 2. Configure DNS records (SPF, DKIM, Return Path) + 3. Verify the domain + + **Quick Reference:** + - `POST /domains` - Add a new domain + - `GET /domains` - List all domains + - `POST /domains/{id}/verify` - Check DNS and verify domain + - `DELETE /domains/{id}` - Remove a domain + """ return DomainsResource(self._client) @cached_property def suppressions(self) -> SuppressionsResource: + """Manage the suppression list. + + Suppressed email addresses will not receive any emails. Addresses are + automatically suppressed when they hard bounce or file spam complaints. + + **Quick Reference:** + - `GET /suppressions` - List suppressed addresses + - `POST /suppressions` - Add to suppression list + - `DELETE /suppressions/{email}` - Remove from suppression list + - `GET /suppressions/{email}` - Check if address is suppressed + """ return SuppressionsResource(self._client) @cached_property def webhooks(self) -> WebhooksResource: + """Configure webhook endpoints for real-time notifications. + + Webhooks notify your application when email events occur: + - Email delivered, bounced, or failed + - Email opened or link clicked + - Spam complaint received + + **Quick Reference:** + - `POST /webhooks` - Create a webhook endpoint + - `GET /webhooks` - List all webhooks + - `POST /webhooks/{id}/test` - Test a webhook with sample data + - `PATCH /webhooks/{id}` - Update webhook configuration + - `DELETE /webhooks/{id}` - Remove a webhook + - `GET /webhooks/{id}/deliveries` - List delivery attempts + - `GET /webhooks/{id}/deliveries/{deliveryId}` - Get delivery details + - `POST /webhooks/{id}/deliveries/{deliveryId}/replay` - Replay a delivery + + ## Webhook Signatures + + All webhooks are cryptographically signed using RSA-SHA256 for security. + Each webhook request includes: + + | Header | Description | + |--------|-------------| + | `X-Ark-Signature` | Base64-encoded RSA-SHA256 signature of the request body | + | `X-Ark-Signature-KID` | Key ID identifying which public key was used | + + Verify signatures by fetching the public key from: + ``` + GET https://mail.arkhq.io/.well-known/jwks.json + ``` + + ```javascript + const crypto = require('crypto'); + + async function verifyWebhook(payload, signatureBase64, publicKey) { + const signature = Buffer.from(signatureBase64, 'base64'); + const verifier = crypto.createVerify('RSA-SHA256'); + verifier.update(payload); + return verifier.verify(publicKey, signature); + } + + // In your webhook handler: + const isValid = await verifyWebhook( + rawBody, + req.headers['x-ark-signature'], + cachedPublicKey + ); + ``` + + **Important:** Always verify signatures before processing webhook data. + See the [Webhook Integration Guide](/guides/webhook-integration) for complete examples. + """ return WebhooksResource(self._client) @cached_property def tracking(self) -> TrackingResource: + """Manage track domains for open and click tracking. + + Track domains enable you to track when recipients: + - Open your emails (tracking pixel) + - Click links in your emails + + **Setup Process:** + 1. Create a track domain with `POST /tracking` + 2. Add the CNAME record to your DNS + 3. Verify DNS with `POST /tracking/{id}/verify` + 4. Track domain is ready when `dnsOk` is true + + **Quick Reference:** + - `POST /tracking` - Create a new track domain + - `GET /tracking` - List all track domains + - `GET /tracking/{id}` - Get track domain details + - `POST /tracking/{id}/verify` - Verify DNS configuration + - `PATCH /tracking/{id}` - Enable/disable tracking features + - `DELETE /tracking/{id}` - Remove a track domain + """ return TrackingResource(self._client) @cached_property def usage(self) -> UsageResource: + """Per-tenant usage analytics and bulk reporting. + + Track email sending statistics for each tenant to power billing, dashboards, and monitoring. + + **Single Tenant Usage:** + - `GET /tenants/{id}/usage` - Get usage stats for a specific tenant + - `GET /tenants/{id}/usage/timeseries` - Get time-bucketed data for charts + + **Bulk Usage:** + - `GET /usage/tenants` - Get usage for all tenants (paginated, sortable) + - `GET /usage/export` - Export usage data as CSV, JSONL, or JSON + + **Period Formats:** + - Shortcuts: `today`, `yesterday`, `this_month`, `last_month`, `last_7_days`, `last_30_days` + - Month: `2024-01` + - Date range: `2024-01-01..2024-01-15` + """ return UsageResource(self._client) @cached_property @@ -201,7 +328,7 @@ def retrieve( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return self._get( - f"/tenants/{tenant_id}", + path_template("/tenants/{tenant_id}", tenant_id=tenant_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -253,7 +380,7 @@ def update( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return self._patch( - f"/tenants/{tenant_id}", + path_template("/tenants/{tenant_id}", tenant_id=tenant_id), body=maybe_transform( { "metadata": metadata, @@ -347,7 +474,7 @@ def delete( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return self._delete( - f"/tenants/{tenant_id}", + path_template("/tenants/{tenant_id}", tenant_id=tenant_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -356,28 +483,155 @@ def delete( class AsyncTenantsResource(AsyncAPIResource): + """Manage tenants (your customers). + + Create a tenant for each of your customers to track their email sending separately. + Store the tenant `id` in your database and use `metadata` for any custom data. + + **Quick Reference:** + - `POST /tenants` - Create a new tenant + - `GET /tenants` - List all tenants (paginated) + - `GET /tenants/{id}` - Get tenant details + - `PATCH /tenants/{id}` - Update tenant name, metadata, or status + - `DELETE /tenants/{id}` - Delete a tenant + """ + @cached_property def credentials(self) -> AsyncCredentialsResource: return AsyncCredentialsResource(self._client) @cached_property def domains(self) -> AsyncDomainsResource: + """Manage sending domains. + + Before you can send emails, you need to: + 1. Add a domain + 2. Configure DNS records (SPF, DKIM, Return Path) + 3. Verify the domain + + **Quick Reference:** + - `POST /domains` - Add a new domain + - `GET /domains` - List all domains + - `POST /domains/{id}/verify` - Check DNS and verify domain + - `DELETE /domains/{id}` - Remove a domain + """ return AsyncDomainsResource(self._client) @cached_property def suppressions(self) -> AsyncSuppressionsResource: + """Manage the suppression list. + + Suppressed email addresses will not receive any emails. Addresses are + automatically suppressed when they hard bounce or file spam complaints. + + **Quick Reference:** + - `GET /suppressions` - List suppressed addresses + - `POST /suppressions` - Add to suppression list + - `DELETE /suppressions/{email}` - Remove from suppression list + - `GET /suppressions/{email}` - Check if address is suppressed + """ return AsyncSuppressionsResource(self._client) @cached_property def webhooks(self) -> AsyncWebhooksResource: + """Configure webhook endpoints for real-time notifications. + + Webhooks notify your application when email events occur: + - Email delivered, bounced, or failed + - Email opened or link clicked + - Spam complaint received + + **Quick Reference:** + - `POST /webhooks` - Create a webhook endpoint + - `GET /webhooks` - List all webhooks + - `POST /webhooks/{id}/test` - Test a webhook with sample data + - `PATCH /webhooks/{id}` - Update webhook configuration + - `DELETE /webhooks/{id}` - Remove a webhook + - `GET /webhooks/{id}/deliveries` - List delivery attempts + - `GET /webhooks/{id}/deliveries/{deliveryId}` - Get delivery details + - `POST /webhooks/{id}/deliveries/{deliveryId}/replay` - Replay a delivery + + ## Webhook Signatures + + All webhooks are cryptographically signed using RSA-SHA256 for security. + Each webhook request includes: + + | Header | Description | + |--------|-------------| + | `X-Ark-Signature` | Base64-encoded RSA-SHA256 signature of the request body | + | `X-Ark-Signature-KID` | Key ID identifying which public key was used | + + Verify signatures by fetching the public key from: + ``` + GET https://mail.arkhq.io/.well-known/jwks.json + ``` + + ```javascript + const crypto = require('crypto'); + + async function verifyWebhook(payload, signatureBase64, publicKey) { + const signature = Buffer.from(signatureBase64, 'base64'); + const verifier = crypto.createVerify('RSA-SHA256'); + verifier.update(payload); + return verifier.verify(publicKey, signature); + } + + // In your webhook handler: + const isValid = await verifyWebhook( + rawBody, + req.headers['x-ark-signature'], + cachedPublicKey + ); + ``` + + **Important:** Always verify signatures before processing webhook data. + See the [Webhook Integration Guide](/guides/webhook-integration) for complete examples. + """ return AsyncWebhooksResource(self._client) @cached_property def tracking(self) -> AsyncTrackingResource: + """Manage track domains for open and click tracking. + + Track domains enable you to track when recipients: + - Open your emails (tracking pixel) + - Click links in your emails + + **Setup Process:** + 1. Create a track domain with `POST /tracking` + 2. Add the CNAME record to your DNS + 3. Verify DNS with `POST /tracking/{id}/verify` + 4. Track domain is ready when `dnsOk` is true + + **Quick Reference:** + - `POST /tracking` - Create a new track domain + - `GET /tracking` - List all track domains + - `GET /tracking/{id}` - Get track domain details + - `POST /tracking/{id}/verify` - Verify DNS configuration + - `PATCH /tracking/{id}` - Enable/disable tracking features + - `DELETE /tracking/{id}` - Remove a track domain + """ return AsyncTrackingResource(self._client) @cached_property def usage(self) -> AsyncUsageResource: + """Per-tenant usage analytics and bulk reporting. + + Track email sending statistics for each tenant to power billing, dashboards, and monitoring. + + **Single Tenant Usage:** + - `GET /tenants/{id}/usage` - Get usage stats for a specific tenant + - `GET /tenants/{id}/usage/timeseries` - Get time-bucketed data for charts + + **Bulk Usage:** + - `GET /usage/tenants` - Get usage for all tenants (paginated, sortable) + - `GET /usage/export` - Export usage data as CSV, JSONL, or JSON + + **Period Formats:** + - Shortcuts: `today`, `yesterday`, `this_month`, `last_month`, `last_7_days`, `last_30_days` + - Month: `2024-01` + - Date range: `2024-01-01..2024-01-15` + """ return AsyncUsageResource(self._client) @cached_property @@ -479,7 +733,7 @@ async def retrieve( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return await self._get( - f"/tenants/{tenant_id}", + path_template("/tenants/{tenant_id}", tenant_id=tenant_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -531,7 +785,7 @@ async def update( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return await self._patch( - f"/tenants/{tenant_id}", + path_template("/tenants/{tenant_id}", tenant_id=tenant_id), body=await async_maybe_transform( { "metadata": metadata, @@ -625,7 +879,7 @@ async def delete( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return await self._delete( - f"/tenants/{tenant_id}", + path_template("/tenants/{tenant_id}", tenant_id=tenant_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -659,22 +913,136 @@ def credentials(self) -> CredentialsResourceWithRawResponse: @cached_property def domains(self) -> DomainsResourceWithRawResponse: + """Manage sending domains. + + Before you can send emails, you need to: + 1. Add a domain + 2. Configure DNS records (SPF, DKIM, Return Path) + 3. Verify the domain + + **Quick Reference:** + - `POST /domains` - Add a new domain + - `GET /domains` - List all domains + - `POST /domains/{id}/verify` - Check DNS and verify domain + - `DELETE /domains/{id}` - Remove a domain + """ return DomainsResourceWithRawResponse(self._tenants.domains) @cached_property def suppressions(self) -> SuppressionsResourceWithRawResponse: + """Manage the suppression list. + + Suppressed email addresses will not receive any emails. Addresses are + automatically suppressed when they hard bounce or file spam complaints. + + **Quick Reference:** + - `GET /suppressions` - List suppressed addresses + - `POST /suppressions` - Add to suppression list + - `DELETE /suppressions/{email}` - Remove from suppression list + - `GET /suppressions/{email}` - Check if address is suppressed + """ return SuppressionsResourceWithRawResponse(self._tenants.suppressions) @cached_property def webhooks(self) -> WebhooksResourceWithRawResponse: + """Configure webhook endpoints for real-time notifications. + + Webhooks notify your application when email events occur: + - Email delivered, bounced, or failed + - Email opened or link clicked + - Spam complaint received + + **Quick Reference:** + - `POST /webhooks` - Create a webhook endpoint + - `GET /webhooks` - List all webhooks + - `POST /webhooks/{id}/test` - Test a webhook with sample data + - `PATCH /webhooks/{id}` - Update webhook configuration + - `DELETE /webhooks/{id}` - Remove a webhook + - `GET /webhooks/{id}/deliveries` - List delivery attempts + - `GET /webhooks/{id}/deliveries/{deliveryId}` - Get delivery details + - `POST /webhooks/{id}/deliveries/{deliveryId}/replay` - Replay a delivery + + ## Webhook Signatures + + All webhooks are cryptographically signed using RSA-SHA256 for security. + Each webhook request includes: + + | Header | Description | + |--------|-------------| + | `X-Ark-Signature` | Base64-encoded RSA-SHA256 signature of the request body | + | `X-Ark-Signature-KID` | Key ID identifying which public key was used | + + Verify signatures by fetching the public key from: + ``` + GET https://mail.arkhq.io/.well-known/jwks.json + ``` + + ```javascript + const crypto = require('crypto'); + + async function verifyWebhook(payload, signatureBase64, publicKey) { + const signature = Buffer.from(signatureBase64, 'base64'); + const verifier = crypto.createVerify('RSA-SHA256'); + verifier.update(payload); + return verifier.verify(publicKey, signature); + } + + // In your webhook handler: + const isValid = await verifyWebhook( + rawBody, + req.headers['x-ark-signature'], + cachedPublicKey + ); + ``` + + **Important:** Always verify signatures before processing webhook data. + See the [Webhook Integration Guide](/guides/webhook-integration) for complete examples. + """ return WebhooksResourceWithRawResponse(self._tenants.webhooks) @cached_property def tracking(self) -> TrackingResourceWithRawResponse: + """Manage track domains for open and click tracking. + + Track domains enable you to track when recipients: + - Open your emails (tracking pixel) + - Click links in your emails + + **Setup Process:** + 1. Create a track domain with `POST /tracking` + 2. Add the CNAME record to your DNS + 3. Verify DNS with `POST /tracking/{id}/verify` + 4. Track domain is ready when `dnsOk` is true + + **Quick Reference:** + - `POST /tracking` - Create a new track domain + - `GET /tracking` - List all track domains + - `GET /tracking/{id}` - Get track domain details + - `POST /tracking/{id}/verify` - Verify DNS configuration + - `PATCH /tracking/{id}` - Enable/disable tracking features + - `DELETE /tracking/{id}` - Remove a track domain + """ return TrackingResourceWithRawResponse(self._tenants.tracking) @cached_property def usage(self) -> UsageResourceWithRawResponse: + """Per-tenant usage analytics and bulk reporting. + + Track email sending statistics for each tenant to power billing, dashboards, and monitoring. + + **Single Tenant Usage:** + - `GET /tenants/{id}/usage` - Get usage stats for a specific tenant + - `GET /tenants/{id}/usage/timeseries` - Get time-bucketed data for charts + + **Bulk Usage:** + - `GET /usage/tenants` - Get usage for all tenants (paginated, sortable) + - `GET /usage/export` - Export usage data as CSV, JSONL, or JSON + + **Period Formats:** + - Shortcuts: `today`, `yesterday`, `this_month`, `last_month`, `last_7_days`, `last_30_days` + - Month: `2024-01` + - Date range: `2024-01-01..2024-01-15` + """ return UsageResourceWithRawResponse(self._tenants.usage) @@ -704,22 +1072,136 @@ def credentials(self) -> AsyncCredentialsResourceWithRawResponse: @cached_property def domains(self) -> AsyncDomainsResourceWithRawResponse: + """Manage sending domains. + + Before you can send emails, you need to: + 1. Add a domain + 2. Configure DNS records (SPF, DKIM, Return Path) + 3. Verify the domain + + **Quick Reference:** + - `POST /domains` - Add a new domain + - `GET /domains` - List all domains + - `POST /domains/{id}/verify` - Check DNS and verify domain + - `DELETE /domains/{id}` - Remove a domain + """ return AsyncDomainsResourceWithRawResponse(self._tenants.domains) @cached_property def suppressions(self) -> AsyncSuppressionsResourceWithRawResponse: + """Manage the suppression list. + + Suppressed email addresses will not receive any emails. Addresses are + automatically suppressed when they hard bounce or file spam complaints. + + **Quick Reference:** + - `GET /suppressions` - List suppressed addresses + - `POST /suppressions` - Add to suppression list + - `DELETE /suppressions/{email}` - Remove from suppression list + - `GET /suppressions/{email}` - Check if address is suppressed + """ return AsyncSuppressionsResourceWithRawResponse(self._tenants.suppressions) @cached_property def webhooks(self) -> AsyncWebhooksResourceWithRawResponse: + """Configure webhook endpoints for real-time notifications. + + Webhooks notify your application when email events occur: + - Email delivered, bounced, or failed + - Email opened or link clicked + - Spam complaint received + + **Quick Reference:** + - `POST /webhooks` - Create a webhook endpoint + - `GET /webhooks` - List all webhooks + - `POST /webhooks/{id}/test` - Test a webhook with sample data + - `PATCH /webhooks/{id}` - Update webhook configuration + - `DELETE /webhooks/{id}` - Remove a webhook + - `GET /webhooks/{id}/deliveries` - List delivery attempts + - `GET /webhooks/{id}/deliveries/{deliveryId}` - Get delivery details + - `POST /webhooks/{id}/deliveries/{deliveryId}/replay` - Replay a delivery + + ## Webhook Signatures + + All webhooks are cryptographically signed using RSA-SHA256 for security. + Each webhook request includes: + + | Header | Description | + |--------|-------------| + | `X-Ark-Signature` | Base64-encoded RSA-SHA256 signature of the request body | + | `X-Ark-Signature-KID` | Key ID identifying which public key was used | + + Verify signatures by fetching the public key from: + ``` + GET https://mail.arkhq.io/.well-known/jwks.json + ``` + + ```javascript + const crypto = require('crypto'); + + async function verifyWebhook(payload, signatureBase64, publicKey) { + const signature = Buffer.from(signatureBase64, 'base64'); + const verifier = crypto.createVerify('RSA-SHA256'); + verifier.update(payload); + return verifier.verify(publicKey, signature); + } + + // In your webhook handler: + const isValid = await verifyWebhook( + rawBody, + req.headers['x-ark-signature'], + cachedPublicKey + ); + ``` + + **Important:** Always verify signatures before processing webhook data. + See the [Webhook Integration Guide](/guides/webhook-integration) for complete examples. + """ return AsyncWebhooksResourceWithRawResponse(self._tenants.webhooks) @cached_property def tracking(self) -> AsyncTrackingResourceWithRawResponse: + """Manage track domains for open and click tracking. + + Track domains enable you to track when recipients: + - Open your emails (tracking pixel) + - Click links in your emails + + **Setup Process:** + 1. Create a track domain with `POST /tracking` + 2. Add the CNAME record to your DNS + 3. Verify DNS with `POST /tracking/{id}/verify` + 4. Track domain is ready when `dnsOk` is true + + **Quick Reference:** + - `POST /tracking` - Create a new track domain + - `GET /tracking` - List all track domains + - `GET /tracking/{id}` - Get track domain details + - `POST /tracking/{id}/verify` - Verify DNS configuration + - `PATCH /tracking/{id}` - Enable/disable tracking features + - `DELETE /tracking/{id}` - Remove a track domain + """ return AsyncTrackingResourceWithRawResponse(self._tenants.tracking) @cached_property def usage(self) -> AsyncUsageResourceWithRawResponse: + """Per-tenant usage analytics and bulk reporting. + + Track email sending statistics for each tenant to power billing, dashboards, and monitoring. + + **Single Tenant Usage:** + - `GET /tenants/{id}/usage` - Get usage stats for a specific tenant + - `GET /tenants/{id}/usage/timeseries` - Get time-bucketed data for charts + + **Bulk Usage:** + - `GET /usage/tenants` - Get usage for all tenants (paginated, sortable) + - `GET /usage/export` - Export usage data as CSV, JSONL, or JSON + + **Period Formats:** + - Shortcuts: `today`, `yesterday`, `this_month`, `last_month`, `last_7_days`, `last_30_days` + - Month: `2024-01` + - Date range: `2024-01-01..2024-01-15` + """ return AsyncUsageResourceWithRawResponse(self._tenants.usage) @@ -749,22 +1231,136 @@ def credentials(self) -> CredentialsResourceWithStreamingResponse: @cached_property def domains(self) -> DomainsResourceWithStreamingResponse: + """Manage sending domains. + + Before you can send emails, you need to: + 1. Add a domain + 2. Configure DNS records (SPF, DKIM, Return Path) + 3. Verify the domain + + **Quick Reference:** + - `POST /domains` - Add a new domain + - `GET /domains` - List all domains + - `POST /domains/{id}/verify` - Check DNS and verify domain + - `DELETE /domains/{id}` - Remove a domain + """ return DomainsResourceWithStreamingResponse(self._tenants.domains) @cached_property def suppressions(self) -> SuppressionsResourceWithStreamingResponse: + """Manage the suppression list. + + Suppressed email addresses will not receive any emails. Addresses are + automatically suppressed when they hard bounce or file spam complaints. + + **Quick Reference:** + - `GET /suppressions` - List suppressed addresses + - `POST /suppressions` - Add to suppression list + - `DELETE /suppressions/{email}` - Remove from suppression list + - `GET /suppressions/{email}` - Check if address is suppressed + """ return SuppressionsResourceWithStreamingResponse(self._tenants.suppressions) @cached_property def webhooks(self) -> WebhooksResourceWithStreamingResponse: + """Configure webhook endpoints for real-time notifications. + + Webhooks notify your application when email events occur: + - Email delivered, bounced, or failed + - Email opened or link clicked + - Spam complaint received + + **Quick Reference:** + - `POST /webhooks` - Create a webhook endpoint + - `GET /webhooks` - List all webhooks + - `POST /webhooks/{id}/test` - Test a webhook with sample data + - `PATCH /webhooks/{id}` - Update webhook configuration + - `DELETE /webhooks/{id}` - Remove a webhook + - `GET /webhooks/{id}/deliveries` - List delivery attempts + - `GET /webhooks/{id}/deliveries/{deliveryId}` - Get delivery details + - `POST /webhooks/{id}/deliveries/{deliveryId}/replay` - Replay a delivery + + ## Webhook Signatures + + All webhooks are cryptographically signed using RSA-SHA256 for security. + Each webhook request includes: + + | Header | Description | + |--------|-------------| + | `X-Ark-Signature` | Base64-encoded RSA-SHA256 signature of the request body | + | `X-Ark-Signature-KID` | Key ID identifying which public key was used | + + Verify signatures by fetching the public key from: + ``` + GET https://mail.arkhq.io/.well-known/jwks.json + ``` + + ```javascript + const crypto = require('crypto'); + + async function verifyWebhook(payload, signatureBase64, publicKey) { + const signature = Buffer.from(signatureBase64, 'base64'); + const verifier = crypto.createVerify('RSA-SHA256'); + verifier.update(payload); + return verifier.verify(publicKey, signature); + } + + // In your webhook handler: + const isValid = await verifyWebhook( + rawBody, + req.headers['x-ark-signature'], + cachedPublicKey + ); + ``` + + **Important:** Always verify signatures before processing webhook data. + See the [Webhook Integration Guide](/guides/webhook-integration) for complete examples. + """ return WebhooksResourceWithStreamingResponse(self._tenants.webhooks) @cached_property def tracking(self) -> TrackingResourceWithStreamingResponse: + """Manage track domains for open and click tracking. + + Track domains enable you to track when recipients: + - Open your emails (tracking pixel) + - Click links in your emails + + **Setup Process:** + 1. Create a track domain with `POST /tracking` + 2. Add the CNAME record to your DNS + 3. Verify DNS with `POST /tracking/{id}/verify` + 4. Track domain is ready when `dnsOk` is true + + **Quick Reference:** + - `POST /tracking` - Create a new track domain + - `GET /tracking` - List all track domains + - `GET /tracking/{id}` - Get track domain details + - `POST /tracking/{id}/verify` - Verify DNS configuration + - `PATCH /tracking/{id}` - Enable/disable tracking features + - `DELETE /tracking/{id}` - Remove a track domain + """ return TrackingResourceWithStreamingResponse(self._tenants.tracking) @cached_property def usage(self) -> UsageResourceWithStreamingResponse: + """Per-tenant usage analytics and bulk reporting. + + Track email sending statistics for each tenant to power billing, dashboards, and monitoring. + + **Single Tenant Usage:** + - `GET /tenants/{id}/usage` - Get usage stats for a specific tenant + - `GET /tenants/{id}/usage/timeseries` - Get time-bucketed data for charts + + **Bulk Usage:** + - `GET /usage/tenants` - Get usage for all tenants (paginated, sortable) + - `GET /usage/export` - Export usage data as CSV, JSONL, or JSON + + **Period Formats:** + - Shortcuts: `today`, `yesterday`, `this_month`, `last_month`, `last_7_days`, `last_30_days` + - Month: `2024-01` + - Date range: `2024-01-01..2024-01-15` + """ return UsageResourceWithStreamingResponse(self._tenants.usage) @@ -794,20 +1390,134 @@ def credentials(self) -> AsyncCredentialsResourceWithStreamingResponse: @cached_property def domains(self) -> AsyncDomainsResourceWithStreamingResponse: + """Manage sending domains. + + Before you can send emails, you need to: + 1. Add a domain + 2. Configure DNS records (SPF, DKIM, Return Path) + 3. Verify the domain + + **Quick Reference:** + - `POST /domains` - Add a new domain + - `GET /domains` - List all domains + - `POST /domains/{id}/verify` - Check DNS and verify domain + - `DELETE /domains/{id}` - Remove a domain + """ return AsyncDomainsResourceWithStreamingResponse(self._tenants.domains) @cached_property def suppressions(self) -> AsyncSuppressionsResourceWithStreamingResponse: + """Manage the suppression list. + + Suppressed email addresses will not receive any emails. Addresses are + automatically suppressed when they hard bounce or file spam complaints. + + **Quick Reference:** + - `GET /suppressions` - List suppressed addresses + - `POST /suppressions` - Add to suppression list + - `DELETE /suppressions/{email}` - Remove from suppression list + - `GET /suppressions/{email}` - Check if address is suppressed + """ return AsyncSuppressionsResourceWithStreamingResponse(self._tenants.suppressions) @cached_property def webhooks(self) -> AsyncWebhooksResourceWithStreamingResponse: + """Configure webhook endpoints for real-time notifications. + + Webhooks notify your application when email events occur: + - Email delivered, bounced, or failed + - Email opened or link clicked + - Spam complaint received + + **Quick Reference:** + - `POST /webhooks` - Create a webhook endpoint + - `GET /webhooks` - List all webhooks + - `POST /webhooks/{id}/test` - Test a webhook with sample data + - `PATCH /webhooks/{id}` - Update webhook configuration + - `DELETE /webhooks/{id}` - Remove a webhook + - `GET /webhooks/{id}/deliveries` - List delivery attempts + - `GET /webhooks/{id}/deliveries/{deliveryId}` - Get delivery details + - `POST /webhooks/{id}/deliveries/{deliveryId}/replay` - Replay a delivery + + ## Webhook Signatures + + All webhooks are cryptographically signed using RSA-SHA256 for security. + Each webhook request includes: + + | Header | Description | + |--------|-------------| + | `X-Ark-Signature` | Base64-encoded RSA-SHA256 signature of the request body | + | `X-Ark-Signature-KID` | Key ID identifying which public key was used | + + Verify signatures by fetching the public key from: + ``` + GET https://mail.arkhq.io/.well-known/jwks.json + ``` + + ```javascript + const crypto = require('crypto'); + + async function verifyWebhook(payload, signatureBase64, publicKey) { + const signature = Buffer.from(signatureBase64, 'base64'); + const verifier = crypto.createVerify('RSA-SHA256'); + verifier.update(payload); + return verifier.verify(publicKey, signature); + } + + // In your webhook handler: + const isValid = await verifyWebhook( + rawBody, + req.headers['x-ark-signature'], + cachedPublicKey + ); + ``` + + **Important:** Always verify signatures before processing webhook data. + See the [Webhook Integration Guide](/guides/webhook-integration) for complete examples. + """ return AsyncWebhooksResourceWithStreamingResponse(self._tenants.webhooks) @cached_property def tracking(self) -> AsyncTrackingResourceWithStreamingResponse: + """Manage track domains for open and click tracking. + + Track domains enable you to track when recipients: + - Open your emails (tracking pixel) + - Click links in your emails + + **Setup Process:** + 1. Create a track domain with `POST /tracking` + 2. Add the CNAME record to your DNS + 3. Verify DNS with `POST /tracking/{id}/verify` + 4. Track domain is ready when `dnsOk` is true + + **Quick Reference:** + - `POST /tracking` - Create a new track domain + - `GET /tracking` - List all track domains + - `GET /tracking/{id}` - Get track domain details + - `POST /tracking/{id}/verify` - Verify DNS configuration + - `PATCH /tracking/{id}` - Enable/disable tracking features + - `DELETE /tracking/{id}` - Remove a track domain + """ return AsyncTrackingResourceWithStreamingResponse(self._tenants.tracking) @cached_property def usage(self) -> AsyncUsageResourceWithStreamingResponse: + """Per-tenant usage analytics and bulk reporting. + + Track email sending statistics for each tenant to power billing, dashboards, and monitoring. + + **Single Tenant Usage:** + - `GET /tenants/{id}/usage` - Get usage stats for a specific tenant + - `GET /tenants/{id}/usage/timeseries` - Get time-bucketed data for charts + + **Bulk Usage:** + - `GET /usage/tenants` - Get usage for all tenants (paginated, sortable) + - `GET /usage/export` - Export usage data as CSV, JSONL, or JSON + + **Period Formats:** + - Shortcuts: `today`, `yesterday`, `this_month`, `last_month`, `last_7_days`, `last_30_days` + - Month: `2024-01` + - Date range: `2024-01-01..2024-01-15` + """ return AsyncUsageResourceWithStreamingResponse(self._tenants.usage) diff --git a/src/ark/resources/tenants/tracking.py b/src/ark/resources/tenants/tracking.py index 995292f..0b683da 100644 --- a/src/ark/resources/tenants/tracking.py +++ b/src/ark/resources/tenants/tracking.py @@ -7,7 +7,7 @@ import httpx from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given -from ..._utils import maybe_transform, async_maybe_transform +from ..._utils import path_template, maybe_transform, async_maybe_transform from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import ( @@ -29,6 +29,27 @@ class TrackingResource(SyncAPIResource): + """Manage track domains for open and click tracking. + + Track domains enable you to track when recipients: + - Open your emails (tracking pixel) + - Click links in your emails + + **Setup Process:** + 1. Create a track domain with `POST /tracking` + 2. Add the CNAME record to your DNS + 3. Verify DNS with `POST /tracking/{id}/verify` + 4. Track domain is ready when `dnsOk` is true + + **Quick Reference:** + - `POST /tracking` - Create a new track domain + - `GET /tracking` - List all track domains + - `GET /tracking/{id}` - Get track domain details + - `POST /tracking/{id}/verify` - Verify DNS configuration + - `PATCH /tracking/{id}` - Enable/disable tracking features + - `DELETE /tracking/{id}` - Remove a track domain + """ + @cached_property def with_raw_response(self) -> TrackingResourceWithRawResponse: """ @@ -92,7 +113,7 @@ def create( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return self._post( - f"/tenants/{tenant_id}/tracking", + path_template("/tenants/{tenant_id}/tracking", tenant_id=tenant_id), body=maybe_transform( { "domain_id": domain_id, @@ -138,7 +159,7 @@ def retrieve( if not tracking_id: raise ValueError(f"Expected a non-empty value for `tracking_id` but received {tracking_id!r}") return self._get( - f"/tenants/{tenant_id}/tracking/{tracking_id}", + path_template("/tenants/{tenant_id}/tracking/{tracking_id}", tenant_id=tenant_id, tracking_id=tracking_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -193,7 +214,7 @@ def update( if not tracking_id: raise ValueError(f"Expected a non-empty value for `tracking_id` but received {tracking_id!r}") return self._patch( - f"/tenants/{tenant_id}/tracking/{tracking_id}", + path_template("/tenants/{tenant_id}/tracking/{tracking_id}", tenant_id=tenant_id, tracking_id=tracking_id), body=maybe_transform( { "excluded_click_domains": excluded_click_domains, @@ -237,7 +258,7 @@ def list( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return self._get( - f"/tenants/{tenant_id}/tracking", + path_template("/tenants/{tenant_id}/tracking", tenant_id=tenant_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -275,7 +296,7 @@ def delete( if not tracking_id: raise ValueError(f"Expected a non-empty value for `tracking_id` but received {tracking_id!r}") return self._delete( - f"/tenants/{tenant_id}/tracking/{tracking_id}", + path_template("/tenants/{tenant_id}/tracking/{tracking_id}", tenant_id=tenant_id, tracking_id=tracking_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -314,7 +335,9 @@ def verify( if not tracking_id: raise ValueError(f"Expected a non-empty value for `tracking_id` but received {tracking_id!r}") return self._post( - f"/tenants/{tenant_id}/tracking/{tracking_id}/verify", + path_template( + "/tenants/{tenant_id}/tracking/{tracking_id}/verify", tenant_id=tenant_id, tracking_id=tracking_id + ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -323,6 +346,27 @@ def verify( class AsyncTrackingResource(AsyncAPIResource): + """Manage track domains for open and click tracking. + + Track domains enable you to track when recipients: + - Open your emails (tracking pixel) + - Click links in your emails + + **Setup Process:** + 1. Create a track domain with `POST /tracking` + 2. Add the CNAME record to your DNS + 3. Verify DNS with `POST /tracking/{id}/verify` + 4. Track domain is ready when `dnsOk` is true + + **Quick Reference:** + - `POST /tracking` - Create a new track domain + - `GET /tracking` - List all track domains + - `GET /tracking/{id}` - Get track domain details + - `POST /tracking/{id}/verify` - Verify DNS configuration + - `PATCH /tracking/{id}` - Enable/disable tracking features + - `DELETE /tracking/{id}` - Remove a track domain + """ + @cached_property def with_raw_response(self) -> AsyncTrackingResourceWithRawResponse: """ @@ -386,7 +430,7 @@ async def create( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return await self._post( - f"/tenants/{tenant_id}/tracking", + path_template("/tenants/{tenant_id}/tracking", tenant_id=tenant_id), body=await async_maybe_transform( { "domain_id": domain_id, @@ -432,7 +476,7 @@ async def retrieve( if not tracking_id: raise ValueError(f"Expected a non-empty value for `tracking_id` but received {tracking_id!r}") return await self._get( - f"/tenants/{tenant_id}/tracking/{tracking_id}", + path_template("/tenants/{tenant_id}/tracking/{tracking_id}", tenant_id=tenant_id, tracking_id=tracking_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -487,7 +531,7 @@ async def update( if not tracking_id: raise ValueError(f"Expected a non-empty value for `tracking_id` but received {tracking_id!r}") return await self._patch( - f"/tenants/{tenant_id}/tracking/{tracking_id}", + path_template("/tenants/{tenant_id}/tracking/{tracking_id}", tenant_id=tenant_id, tracking_id=tracking_id), body=await async_maybe_transform( { "excluded_click_domains": excluded_click_domains, @@ -531,7 +575,7 @@ async def list( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return await self._get( - f"/tenants/{tenant_id}/tracking", + path_template("/tenants/{tenant_id}/tracking", tenant_id=tenant_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -569,7 +613,7 @@ async def delete( if not tracking_id: raise ValueError(f"Expected a non-empty value for `tracking_id` but received {tracking_id!r}") return await self._delete( - f"/tenants/{tenant_id}/tracking/{tracking_id}", + path_template("/tenants/{tenant_id}/tracking/{tracking_id}", tenant_id=tenant_id, tracking_id=tracking_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -608,7 +652,9 @@ async def verify( if not tracking_id: raise ValueError(f"Expected a non-empty value for `tracking_id` but received {tracking_id!r}") return await self._post( - f"/tenants/{tenant_id}/tracking/{tracking_id}/verify", + path_template( + "/tenants/{tenant_id}/tracking/{tracking_id}/verify", tenant_id=tenant_id, tracking_id=tracking_id + ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), diff --git a/src/ark/resources/tenants/usage.py b/src/ark/resources/tenants/usage.py index ce82b19..d5192cb 100644 --- a/src/ark/resources/tenants/usage.py +++ b/src/ark/resources/tenants/usage.py @@ -7,7 +7,7 @@ import httpx from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given -from ..._utils import maybe_transform, async_maybe_transform +from ..._utils import path_template, maybe_transform, async_maybe_transform from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import ( @@ -25,6 +25,24 @@ class UsageResource(SyncAPIResource): + """Per-tenant usage analytics and bulk reporting. + + Track email sending statistics for each tenant to power billing, dashboards, and monitoring. + + **Single Tenant Usage:** + - `GET /tenants/{id}/usage` - Get usage stats for a specific tenant + - `GET /tenants/{id}/usage/timeseries` - Get time-bucketed data for charts + + **Bulk Usage:** + - `GET /usage/tenants` - Get usage for all tenants (paginated, sortable) + - `GET /usage/export` - Export usage data as CSV, JSONL, or JSON + + **Period Formats:** + - Shortcuts: `today`, `yesterday`, `this_month`, `last_month`, `last_7_days`, `last_30_days` + - Month: `2024-01` + - Date range: `2024-01-01..2024-01-15` + """ + @cached_property def with_raw_response(self) -> UsageResourceWithRawResponse: """ @@ -103,7 +121,7 @@ def retrieve( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return self._get( - f"/tenants/{tenant_id}/usage", + path_template("/tenants/{tenant_id}/usage", tenant_id=tenant_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -170,7 +188,7 @@ def retrieve_timeseries( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return self._get( - f"/tenants/{tenant_id}/usage/timeseries", + path_template("/tenants/{tenant_id}/usage/timeseries", tenant_id=tenant_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -190,6 +208,24 @@ def retrieve_timeseries( class AsyncUsageResource(AsyncAPIResource): + """Per-tenant usage analytics and bulk reporting. + + Track email sending statistics for each tenant to power billing, dashboards, and monitoring. + + **Single Tenant Usage:** + - `GET /tenants/{id}/usage` - Get usage stats for a specific tenant + - `GET /tenants/{id}/usage/timeseries` - Get time-bucketed data for charts + + **Bulk Usage:** + - `GET /usage/tenants` - Get usage for all tenants (paginated, sortable) + - `GET /usage/export` - Export usage data as CSV, JSONL, or JSON + + **Period Formats:** + - Shortcuts: `today`, `yesterday`, `this_month`, `last_month`, `last_7_days`, `last_30_days` + - Month: `2024-01` + - Date range: `2024-01-01..2024-01-15` + """ + @cached_property def with_raw_response(self) -> AsyncUsageResourceWithRawResponse: """ @@ -268,7 +304,7 @@ async def retrieve( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return await self._get( - f"/tenants/{tenant_id}/usage", + path_template("/tenants/{tenant_id}/usage", tenant_id=tenant_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -335,7 +371,7 @@ async def retrieve_timeseries( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return await self._get( - f"/tenants/{tenant_id}/usage/timeseries", + path_template("/tenants/{tenant_id}/usage/timeseries", tenant_id=tenant_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, diff --git a/src/ark/resources/tenants/webhooks.py b/src/ark/resources/tenants/webhooks.py index ee3a683..cfa0396 100644 --- a/src/ark/resources/tenants/webhooks.py +++ b/src/ark/resources/tenants/webhooks.py @@ -8,7 +8,7 @@ import httpx from ..._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given -from ..._utils import maybe_transform, async_maybe_transform +from ..._utils import path_template, maybe_transform, async_maybe_transform from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import ( @@ -38,6 +38,60 @@ class WebhooksResource(SyncAPIResource): + """Configure webhook endpoints for real-time notifications. + + Webhooks notify your application when email events occur: + - Email delivered, bounced, or failed + - Email opened or link clicked + - Spam complaint received + + **Quick Reference:** + - `POST /webhooks` - Create a webhook endpoint + - `GET /webhooks` - List all webhooks + - `POST /webhooks/{id}/test` - Test a webhook with sample data + - `PATCH /webhooks/{id}` - Update webhook configuration + - `DELETE /webhooks/{id}` - Remove a webhook + - `GET /webhooks/{id}/deliveries` - List delivery attempts + - `GET /webhooks/{id}/deliveries/{deliveryId}` - Get delivery details + - `POST /webhooks/{id}/deliveries/{deliveryId}/replay` - Replay a delivery + + ## Webhook Signatures + + All webhooks are cryptographically signed using RSA-SHA256 for security. + Each webhook request includes: + + | Header | Description | + |--------|-------------| + | `X-Ark-Signature` | Base64-encoded RSA-SHA256 signature of the request body | + | `X-Ark-Signature-KID` | Key ID identifying which public key was used | + + Verify signatures by fetching the public key from: + ``` + GET https://mail.arkhq.io/.well-known/jwks.json + ``` + + ```javascript + const crypto = require('crypto'); + + async function verifyWebhook(payload, signatureBase64, publicKey) { + const signature = Buffer.from(signatureBase64, 'base64'); + const verifier = crypto.createVerify('RSA-SHA256'); + verifier.update(payload); + return verifier.verify(publicKey, signature); + } + + // In your webhook handler: + const isValid = await verifyWebhook( + rawBody, + req.headers['x-ark-signature'], + cachedPublicKey + ); + ``` + + **Important:** Always verify signatures before processing webhook data. + See the [Webhook Integration Guide](/guides/webhook-integration) for complete examples. + """ + @cached_property def with_raw_response(self) -> WebhooksResourceWithRawResponse: """ @@ -133,7 +187,7 @@ def create( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return self._post( - f"/tenants/{tenant_id}/webhooks", + path_template("/tenants/{tenant_id}/webhooks", tenant_id=tenant_id), body=maybe_transform( { "name": name, @@ -179,7 +233,7 @@ def retrieve( if not webhook_id: raise ValueError(f"Expected a non-empty value for `webhook_id` but received {webhook_id!r}") return self._get( - f"/tenants/{tenant_id}/webhooks/{webhook_id}", + path_template("/tenants/{tenant_id}/webhooks/{webhook_id}", tenant_id=tenant_id, webhook_id=webhook_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -220,7 +274,7 @@ def update( if not webhook_id: raise ValueError(f"Expected a non-empty value for `webhook_id` but received {webhook_id!r}") return self._patch( - f"/tenants/{tenant_id}/webhooks/{webhook_id}", + path_template("/tenants/{tenant_id}/webhooks/{webhook_id}", tenant_id=tenant_id, webhook_id=webhook_id), body=maybe_transform( { "all_events": all_events, @@ -263,7 +317,7 @@ def list( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return self._get( - f"/tenants/{tenant_id}/webhooks", + path_template("/tenants/{tenant_id}/webhooks", tenant_id=tenant_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -299,7 +353,7 @@ def delete( if not webhook_id: raise ValueError(f"Expected a non-empty value for `webhook_id` but received {webhook_id!r}") return self._delete( - f"/tenants/{tenant_id}/webhooks/{webhook_id}", + path_template("/tenants/{tenant_id}/webhooks/{webhook_id}", tenant_id=tenant_id, webhook_id=webhook_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -378,7 +432,9 @@ def list_deliveries( if not webhook_id: raise ValueError(f"Expected a non-empty value for `webhook_id` but received {webhook_id!r}") return self._get( - f"/tenants/{tenant_id}/webhooks/{webhook_id}/deliveries", + path_template( + "/tenants/{tenant_id}/webhooks/{webhook_id}/deliveries", tenant_id=tenant_id, webhook_id=webhook_id + ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -446,7 +502,12 @@ def replay_delivery( if not delivery_id: raise ValueError(f"Expected a non-empty value for `delivery_id` but received {delivery_id!r}") return self._post( - f"/tenants/{tenant_id}/webhooks/{webhook_id}/deliveries/{delivery_id}/replay", + path_template( + "/tenants/{tenant_id}/webhooks/{webhook_id}/deliveries/{delivery_id}/replay", + tenant_id=tenant_id, + webhook_id=webhook_id, + delivery_id=delivery_id, + ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -494,7 +555,12 @@ def retrieve_delivery( if not delivery_id: raise ValueError(f"Expected a non-empty value for `delivery_id` but received {delivery_id!r}") return self._get( - f"/tenants/{tenant_id}/webhooks/{webhook_id}/deliveries/{delivery_id}", + path_template( + "/tenants/{tenant_id}/webhooks/{webhook_id}/deliveries/{delivery_id}", + tenant_id=tenant_id, + webhook_id=webhook_id, + delivery_id=delivery_id, + ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -553,7 +619,9 @@ def test( if not webhook_id: raise ValueError(f"Expected a non-empty value for `webhook_id` but received {webhook_id!r}") return self._post( - f"/tenants/{tenant_id}/webhooks/{webhook_id}/test", + path_template( + "/tenants/{tenant_id}/webhooks/{webhook_id}/test", tenant_id=tenant_id, webhook_id=webhook_id + ), body=maybe_transform({"event": event}, webhook_test_params.WebhookTestParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout @@ -563,6 +631,60 @@ def test( class AsyncWebhooksResource(AsyncAPIResource): + """Configure webhook endpoints for real-time notifications. + + Webhooks notify your application when email events occur: + - Email delivered, bounced, or failed + - Email opened or link clicked + - Spam complaint received + + **Quick Reference:** + - `POST /webhooks` - Create a webhook endpoint + - `GET /webhooks` - List all webhooks + - `POST /webhooks/{id}/test` - Test a webhook with sample data + - `PATCH /webhooks/{id}` - Update webhook configuration + - `DELETE /webhooks/{id}` - Remove a webhook + - `GET /webhooks/{id}/deliveries` - List delivery attempts + - `GET /webhooks/{id}/deliveries/{deliveryId}` - Get delivery details + - `POST /webhooks/{id}/deliveries/{deliveryId}/replay` - Replay a delivery + + ## Webhook Signatures + + All webhooks are cryptographically signed using RSA-SHA256 for security. + Each webhook request includes: + + | Header | Description | + |--------|-------------| + | `X-Ark-Signature` | Base64-encoded RSA-SHA256 signature of the request body | + | `X-Ark-Signature-KID` | Key ID identifying which public key was used | + + Verify signatures by fetching the public key from: + ``` + GET https://mail.arkhq.io/.well-known/jwks.json + ``` + + ```javascript + const crypto = require('crypto'); + + async function verifyWebhook(payload, signatureBase64, publicKey) { + const signature = Buffer.from(signatureBase64, 'base64'); + const verifier = crypto.createVerify('RSA-SHA256'); + verifier.update(payload); + return verifier.verify(publicKey, signature); + } + + // In your webhook handler: + const isValid = await verifyWebhook( + rawBody, + req.headers['x-ark-signature'], + cachedPublicKey + ); + ``` + + **Important:** Always verify signatures before processing webhook data. + See the [Webhook Integration Guide](/guides/webhook-integration) for complete examples. + """ + @cached_property def with_raw_response(self) -> AsyncWebhooksResourceWithRawResponse: """ @@ -658,7 +780,7 @@ async def create( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return await self._post( - f"/tenants/{tenant_id}/webhooks", + path_template("/tenants/{tenant_id}/webhooks", tenant_id=tenant_id), body=await async_maybe_transform( { "name": name, @@ -704,7 +826,7 @@ async def retrieve( if not webhook_id: raise ValueError(f"Expected a non-empty value for `webhook_id` but received {webhook_id!r}") return await self._get( - f"/tenants/{tenant_id}/webhooks/{webhook_id}", + path_template("/tenants/{tenant_id}/webhooks/{webhook_id}", tenant_id=tenant_id, webhook_id=webhook_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -745,7 +867,7 @@ async def update( if not webhook_id: raise ValueError(f"Expected a non-empty value for `webhook_id` but received {webhook_id!r}") return await self._patch( - f"/tenants/{tenant_id}/webhooks/{webhook_id}", + path_template("/tenants/{tenant_id}/webhooks/{webhook_id}", tenant_id=tenant_id, webhook_id=webhook_id), body=await async_maybe_transform( { "all_events": all_events, @@ -788,7 +910,7 @@ async def list( if not tenant_id: raise ValueError(f"Expected a non-empty value for `tenant_id` but received {tenant_id!r}") return await self._get( - f"/tenants/{tenant_id}/webhooks", + path_template("/tenants/{tenant_id}/webhooks", tenant_id=tenant_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -824,7 +946,7 @@ async def delete( if not webhook_id: raise ValueError(f"Expected a non-empty value for `webhook_id` but received {webhook_id!r}") return await self._delete( - f"/tenants/{tenant_id}/webhooks/{webhook_id}", + path_template("/tenants/{tenant_id}/webhooks/{webhook_id}", tenant_id=tenant_id, webhook_id=webhook_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -903,7 +1025,9 @@ async def list_deliveries( if not webhook_id: raise ValueError(f"Expected a non-empty value for `webhook_id` but received {webhook_id!r}") return await self._get( - f"/tenants/{tenant_id}/webhooks/{webhook_id}/deliveries", + path_template( + "/tenants/{tenant_id}/webhooks/{webhook_id}/deliveries", tenant_id=tenant_id, webhook_id=webhook_id + ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -971,7 +1095,12 @@ async def replay_delivery( if not delivery_id: raise ValueError(f"Expected a non-empty value for `delivery_id` but received {delivery_id!r}") return await self._post( - f"/tenants/{tenant_id}/webhooks/{webhook_id}/deliveries/{delivery_id}/replay", + path_template( + "/tenants/{tenant_id}/webhooks/{webhook_id}/deliveries/{delivery_id}/replay", + tenant_id=tenant_id, + webhook_id=webhook_id, + delivery_id=delivery_id, + ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -1019,7 +1148,12 @@ async def retrieve_delivery( if not delivery_id: raise ValueError(f"Expected a non-empty value for `delivery_id` but received {delivery_id!r}") return await self._get( - f"/tenants/{tenant_id}/webhooks/{webhook_id}/deliveries/{delivery_id}", + path_template( + "/tenants/{tenant_id}/webhooks/{webhook_id}/deliveries/{delivery_id}", + tenant_id=tenant_id, + webhook_id=webhook_id, + delivery_id=delivery_id, + ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -1078,7 +1212,9 @@ async def test( if not webhook_id: raise ValueError(f"Expected a non-empty value for `webhook_id` but received {webhook_id!r}") return await self._post( - f"/tenants/{tenant_id}/webhooks/{webhook_id}/test", + path_template( + "/tenants/{tenant_id}/webhooks/{webhook_id}/test", tenant_id=tenant_id, webhook_id=webhook_id + ), body=await async_maybe_transform({"event": event}, webhook_test_params.WebhookTestParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout diff --git a/src/ark/resources/usage.py b/src/ark/resources/usage.py index 9a38220..05a4493 100644 --- a/src/ark/resources/usage.py +++ b/src/ark/resources/usage.py @@ -27,6 +27,24 @@ class UsageResource(SyncAPIResource): + """Per-tenant usage analytics and bulk reporting. + + Track email sending statistics for each tenant to power billing, dashboards, and monitoring. + + **Single Tenant Usage:** + - `GET /tenants/{id}/usage` - Get usage stats for a specific tenant + - `GET /tenants/{id}/usage/timeseries` - Get time-bucketed data for charts + + **Bulk Usage:** + - `GET /usage/tenants` - Get usage for all tenants (paginated, sortable) + - `GET /usage/export` - Export usage data as CSV, JSONL, or JSON + + **Period Formats:** + - Shortcuts: `today`, `yesterday`, `this_month`, `last_month`, `last_7_days`, `last_30_days` + - Month: `2024-01` + - Date range: `2024-01-01..2024-01-15` + """ + @cached_property def with_raw_response(self) -> UsageResourceWithRawResponse: """ @@ -318,6 +336,24 @@ def list_tenants( class AsyncUsageResource(AsyncAPIResource): + """Per-tenant usage analytics and bulk reporting. + + Track email sending statistics for each tenant to power billing, dashboards, and monitoring. + + **Single Tenant Usage:** + - `GET /tenants/{id}/usage` - Get usage stats for a specific tenant + - `GET /tenants/{id}/usage/timeseries` - Get time-bucketed data for charts + + **Bulk Usage:** + - `GET /usage/tenants` - Get usage for all tenants (paginated, sortable) + - `GET /usage/export` - Export usage data as CSV, JSONL, or JSON + + **Period Formats:** + - Shortcuts: `today`, `yesterday`, `this_month`, `last_month`, `last_7_days`, `last_30_days` + - Month: `2024-01` + - Date range: `2024-01-01..2024-01-15` + """ + @cached_property def with_raw_response(self) -> AsyncUsageResourceWithRawResponse: """ diff --git a/src/ark/types/email_list_response.py b/src/ark/types/email_list_response.py index dce7d39..21bd18e 100644 --- a/src/ark/types/email_list_response.py +++ b/src/ark/types/email_list_response.py @@ -30,6 +30,9 @@ class EmailListResponse(BaseModel): subject: str + tenant_id: str = FieldInfo(alias="tenantId") + """The tenant ID this email belongs to""" + timestamp: float timestamp_iso: datetime = FieldInfo(alias="timestampIso") diff --git a/src/ark/types/email_retrieve_deliveries_response.py b/src/ark/types/email_retrieve_deliveries_response.py index a923d98..1f3c1bc 100644 --- a/src/ark/types/email_retrieve_deliveries_response.py +++ b/src/ark/types/email_retrieve_deliveries_response.py @@ -170,6 +170,9 @@ class Data(BaseModel): - `bounced` - Bounced by recipient server """ + tenant_id: str = FieldInfo(alias="tenantId") + """The tenant ID this email belongs to""" + class EmailRetrieveDeliveriesResponse(BaseModel): data: Data diff --git a/src/ark/types/email_retrieve_response.py b/src/ark/types/email_retrieve_response.py index d9bc54c..c0de6b1 100644 --- a/src/ark/types/email_retrieve_response.py +++ b/src/ark/types/email_retrieve_response.py @@ -179,6 +179,9 @@ class Data(BaseModel): subject: str """Email subject line""" + tenant_id: str = FieldInfo(alias="tenantId") + """The tenant ID this email belongs to""" + timestamp: float """Unix timestamp when the email was sent""" diff --git a/src/ark/types/email_retry_response.py b/src/ark/types/email_retry_response.py index f63cc99..fe5d8ce 100644 --- a/src/ark/types/email_retry_response.py +++ b/src/ark/types/email_retry_response.py @@ -2,6 +2,8 @@ from typing_extensions import Literal +from pydantic import Field as FieldInfo + from .._models import BaseModel from .shared.api_meta import APIMeta @@ -14,6 +16,9 @@ class Data(BaseModel): message: str + tenant_id: str = FieldInfo(alias="tenantId") + """The tenant ID this email belongs to""" + class EmailRetryResponse(BaseModel): data: Data diff --git a/src/ark/types/email_send_batch_params.py b/src/ark/types/email_send_batch_params.py index ba54d41..38c9caf 100644 --- a/src/ark/types/email_send_batch_params.py +++ b/src/ark/types/email_send_batch_params.py @@ -17,6 +17,17 @@ class EmailSendBatchParams(TypedDict, total=False): from_: Required[Annotated[str, PropertyInfo(alias="from")]] """Sender email for all messages""" + tenant_id: Annotated[Optional[str], PropertyInfo(alias="tenantId")] + """The tenant ID to send this batch from. + + Determines which tenant's configuration (domains, webhooks, tracking) is used. + + - If your API key is scoped to a specific tenant, this must match that tenant or + be omitted. + - If your API key is org-level, specify the tenant to send from. + - If omitted, the organization's default tenant is used. + """ + idempotency_key: Annotated[str, PropertyInfo(alias="Idempotency-Key")] diff --git a/src/ark/types/email_send_batch_response.py b/src/ark/types/email_send_batch_response.py index d32e3fc..06fe526 100644 --- a/src/ark/types/email_send_batch_response.py +++ b/src/ark/types/email_send_batch_response.py @@ -3,6 +3,8 @@ from typing import Dict, Optional from typing_extensions import Literal +from pydantic import Field as FieldInfo + from .._models import BaseModel from .shared.api_meta import APIMeta @@ -24,6 +26,9 @@ class Data(BaseModel): messages: Dict[str, DataMessages] """Map of recipient email to message info""" + tenant_id: str = FieldInfo(alias="tenantId") + """The tenant ID this batch was sent from""" + total: int """Total emails in the batch""" diff --git a/src/ark/types/email_send_params.py b/src/ark/types/email_send_params.py index 0363339..af3e94f 100644 --- a/src/ark/types/email_send_params.py +++ b/src/ark/types/email_send_params.py @@ -79,6 +79,17 @@ class EmailSendParams(TypedDict, total=False): tag: Optional[str] """Tag for categorization and filtering (accepts null)""" + tenant_id: Annotated[Optional[str], PropertyInfo(alias="tenantId")] + """The tenant ID to send this email from. + + Determines which tenant's configuration (domains, webhooks, tracking) is used. + + - If your API key is scoped to a specific tenant, this must match that tenant or + be omitted. + - If your API key is org-level, specify the tenant to send from. + - If omitted, the organization's default tenant is used. + """ + text: Optional[str] """ Plain text body (accepts null, auto-generated from HTML if not provided). diff --git a/src/ark/types/email_send_raw_params.py b/src/ark/types/email_send_raw_params.py index d957f74..56c038b 100644 --- a/src/ark/types/email_send_raw_params.py +++ b/src/ark/types/email_send_raw_params.py @@ -37,3 +37,14 @@ class EmailSendRawParams(TypedDict, total=False): bounce: Optional[bool] """Whether this is a bounce message (accepts null)""" + + tenant_id: Annotated[Optional[str], PropertyInfo(alias="tenantId")] + """The tenant ID to send this email from. + + Determines which tenant's configuration (domains, webhooks, tracking) is used. + + - If your API key is scoped to a specific tenant, this must match that tenant or + be omitted. + - If your API key is org-level, specify the tenant to send from. + - If omitted, the organization's default tenant is used. + """ diff --git a/src/ark/types/email_send_raw_response.py b/src/ark/types/email_send_raw_response.py index 46e7c96..80a61fb 100644 --- a/src/ark/types/email_send_raw_response.py +++ b/src/ark/types/email_send_raw_response.py @@ -18,6 +18,9 @@ class Data(BaseModel): status: Literal["pending", "sent"] """Current delivery status""" + tenant_id: str = FieldInfo(alias="tenantId") + """The tenant ID this email was sent from""" + to: List[str] """List of recipient addresses""" diff --git a/src/ark/types/email_send_response.py b/src/ark/types/email_send_response.py index cb7d814..7c54d65 100644 --- a/src/ark/types/email_send_response.py +++ b/src/ark/types/email_send_response.py @@ -18,6 +18,9 @@ class Data(BaseModel): status: Literal["pending", "sent"] """Current delivery status""" + tenant_id: str = FieldInfo(alias="tenantId") + """The tenant ID this email was sent from""" + to: List[str] """List of recipient addresses""" diff --git a/tests/api_resources/test_emails.py b/tests/api_resources/test_emails.py index 0e37b72..daa4744 100644 --- a/tests/api_resources/test_emails.py +++ b/tests/api_resources/test_emails.py @@ -219,6 +219,7 @@ def test_method_send_with_all_params(self, client: Ark) -> None: }, reply_to="dev@stainless.com", tag="tag", + tenant_id="cm6abc123def456", text="text", idempotency_key="user_123_order_456", ) @@ -299,6 +300,7 @@ def test_method_send_batch_with_all_params(self, client: Ark) -> None: }, ], from_="notifications@myapp.com", + tenant_id="cm6abc123def456", idempotency_key="user_123_order_456", ) assert_matches_type(EmailSendBatchResponse, email, path=["response"]) @@ -363,6 +365,7 @@ def test_method_send_raw_with_all_params(self, client: Ark) -> None: raw_message="x", to=["user@example.com"], bounce=True, + tenant_id="cm6abc123def456", ) assert_matches_type(EmailSendRawResponse, email, path=["response"]) @@ -593,6 +596,7 @@ async def test_method_send_with_all_params(self, async_client: AsyncArk) -> None }, reply_to="dev@stainless.com", tag="tag", + tenant_id="cm6abc123def456", text="text", idempotency_key="user_123_order_456", ) @@ -673,6 +677,7 @@ async def test_method_send_batch_with_all_params(self, async_client: AsyncArk) - }, ], from_="notifications@myapp.com", + tenant_id="cm6abc123def456", idempotency_key="user_123_order_456", ) assert_matches_type(EmailSendBatchResponse, email, path=["response"]) @@ -737,6 +742,7 @@ async def test_method_send_raw_with_all_params(self, async_client: AsyncArk) -> raw_message="x", to=["user@example.com"], bounce=True, + tenant_id="cm6abc123def456", ) assert_matches_type(EmailSendRawResponse, email, path=["response"]) diff --git a/tests/test_client.py b/tests/test_client.py index 73b2133..ead5154 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -951,6 +951,14 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: # Test that the proxy environment variables are set correctly monkeypatch.setenv("HTTPS_PROXY", "https://example.org") + # Delete in case our environment has any proxy env vars set + monkeypatch.delenv("HTTP_PROXY", raising=False) + monkeypatch.delenv("ALL_PROXY", raising=False) + monkeypatch.delenv("NO_PROXY", raising=False) + monkeypatch.delenv("http_proxy", raising=False) + monkeypatch.delenv("https_proxy", raising=False) + monkeypatch.delenv("all_proxy", raising=False) + monkeypatch.delenv("no_proxy", raising=False) client = DefaultHttpxClient() @@ -1859,6 +1867,14 @@ async def test_get_platform(self) -> None: async def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: # Test that the proxy environment variables are set correctly monkeypatch.setenv("HTTPS_PROXY", "https://example.org") + # Delete in case our environment has any proxy env vars set + monkeypatch.delenv("HTTP_PROXY", raising=False) + monkeypatch.delenv("ALL_PROXY", raising=False) + monkeypatch.delenv("NO_PROXY", raising=False) + monkeypatch.delenv("http_proxy", raising=False) + monkeypatch.delenv("https_proxy", raising=False) + monkeypatch.delenv("all_proxy", raising=False) + monkeypatch.delenv("no_proxy", raising=False) client = DefaultAsyncHttpxClient() diff --git a/tests/test_utils/test_path.py b/tests/test_utils/test_path.py new file mode 100644 index 0000000..2143d0e --- /dev/null +++ b/tests/test_utils/test_path.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from ark._utils._path import path_template + + +@pytest.mark.parametrize( + "template, kwargs, expected", + [ + ("/v1/{id}", dict(id="abc"), "/v1/abc"), + ("/v1/{a}/{b}", dict(a="x", b="y"), "/v1/x/y"), + ("/v1/{a}{b}/path/{c}?val={d}#{e}", dict(a="x", b="y", c="z", d="u", e="v"), "/v1/xy/path/z?val=u#v"), + ("/{w}/{w}", dict(w="echo"), "/echo/echo"), + ("/v1/static", {}, "/v1/static"), + ("", {}, ""), + ("/v1/?q={n}&count=10", dict(n=42), "/v1/?q=42&count=10"), + ("/v1/{v}", dict(v=None), "/v1/null"), + ("/v1/{v}", dict(v=True), "/v1/true"), + ("/v1/{v}", dict(v=False), "/v1/false"), + ("/v1/{v}", dict(v=".hidden"), "/v1/.hidden"), # dot prefix ok + ("/v1/{v}", dict(v="file.txt"), "/v1/file.txt"), # dot in middle ok + ("/v1/{v}", dict(v="..."), "/v1/..."), # triple dot ok + ("/v1/{a}{b}", dict(a=".", b="txt"), "/v1/.txt"), # dot var combining with adjacent to be ok + ("/items?q={v}#{f}", dict(v=".", f=".."), "/items?q=.#.."), # dots in query/fragment are fine + ( + "/v1/{a}?query={b}", + dict(a="../../other/endpoint", b="a&bad=true"), + "/v1/..%2F..%2Fother%2Fendpoint?query=a%26bad%3Dtrue", + ), + ("/v1/{val}", dict(val="a/b/c"), "/v1/a%2Fb%2Fc"), + ("/v1/{val}", dict(val="a/b/c?query=value"), "/v1/a%2Fb%2Fc%3Fquery=value"), + ("/v1/{val}", dict(val="a/b/c?query=value&bad=true"), "/v1/a%2Fb%2Fc%3Fquery=value&bad=true"), + ("/v1/{val}", dict(val="%20"), "/v1/%2520"), # escapes escape sequences in input + # Query: slash and ? are safe, # is not + ("/items?q={v}", dict(v="a/b"), "/items?q=a/b"), + ("/items?q={v}", dict(v="a?b"), "/items?q=a?b"), + ("/items?q={v}", dict(v="a#b"), "/items?q=a%23b"), + ("/items?q={v}", dict(v="a b"), "/items?q=a%20b"), + # Fragment: slash and ? are safe + ("/docs#{v}", dict(v="a/b"), "/docs#a/b"), + ("/docs#{v}", dict(v="a?b"), "/docs#a?b"), + # Path: slash, ? and # are all encoded + ("/v1/{v}", dict(v="a/b"), "/v1/a%2Fb"), + ("/v1/{v}", dict(v="a?b"), "/v1/a%3Fb"), + ("/v1/{v}", dict(v="a#b"), "/v1/a%23b"), + # same var encoded differently by component + ( + "/v1/{v}?q={v}#{v}", + dict(v="a/b?c#d"), + "/v1/a%2Fb%3Fc%23d?q=a/b?c%23d#a/b?c%23d", + ), + ("/v1/{val}", dict(val="x?admin=true"), "/v1/x%3Fadmin=true"), # query injection + ("/v1/{val}", dict(val="x#admin"), "/v1/x%23admin"), # fragment injection + ], +) +def test_interpolation(template: str, kwargs: dict[str, Any], expected: str) -> None: + assert path_template(template, **kwargs) == expected + + +def test_missing_kwarg_raises_key_error() -> None: + with pytest.raises(KeyError, match="org_id"): + path_template("/v1/{org_id}") + + +@pytest.mark.parametrize( + "template, kwargs", + [ + ("{a}/path", dict(a=".")), + ("{a}/path", dict(a="..")), + ("/v1/{a}", dict(a=".")), + ("/v1/{a}", dict(a="..")), + ("/v1/{a}/path", dict(a=".")), + ("/v1/{a}/path", dict(a="..")), + ("/v1/{a}{b}", dict(a=".", b=".")), # adjacent vars → ".." + ("/v1/{a}.", dict(a=".")), # var + static → ".." + ("/v1/{a}{b}", dict(a="", b=".")), # empty + dot → "." + ("/v1/%2e/{x}", dict(x="ok")), # encoded dot in static text + ("/v1/%2e./{x}", dict(x="ok")), # mixed encoded ".." in static + ("/v1/.%2E/{x}", dict(x="ok")), # mixed encoded ".." in static + ("/v1/{v}?q=1", dict(v="..")), + ("/v1/{v}#frag", dict(v="..")), + ], +) +def test_dot_segment_rejected(template: str, kwargs: dict[str, Any]) -> None: + with pytest.raises(ValueError, match="dot-segment"): + path_template(template, **kwargs) diff --git a/uv.lock b/uv.lock index d3522cb..202c705 100644 --- a/uv.lock +++ b/uv.lock @@ -250,7 +250,7 @@ requires-dist = [ { name = "httpx-aiohttp", marker = "extra == 'aiohttp'", specifier = ">=0.1.9" }, { name = "pydantic", specifier = ">=1.9.0,<3" }, { name = "sniffio" }, - { name = "typing-extensions", specifier = ">=4.10,<5" }, + { name = "typing-extensions", specifier = ">=4.14,<5" }, ] provides-extras = ["aiohttp"]