diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11e0cdb..e69d562 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,13 @@ on: branches: [main] pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest @@ -16,12 +23,11 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - - name: Install dependencies + - name: Install run: | python -m pip install --upgrade pip python -m pip install -e ".[dev]" @@ -30,7 +36,7 @@ jobs: run: ruff check . - name: Mypy - run: mypy helo + run: mypy sdk_helo_email - name: Pytest run: pytest -q diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml deleted file mode 100644 index 55e460e..0000000 --- a/.github/workflows/integration.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Integration - -# Full live tests against a running Helo API. Kept out of the main CI workflow -# so pushes/PRs stay fast and don't need a reachable API or credentials. -# -# Manual-only: these target a local instance (http://localhost:8000) by default, -# which doesn't exist on a hosted runner, so point them at a reachable -# environment by setting the `HELO_BASE_URL` repository variable and the -# `HELO_API_KEY` secret. Without `HELO_API_KEY` the tests skip. -on: - workflow_dispatch: - -jobs: - integration: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install -e ".[dev]" - - - name: Run integration tests - env: - HELO_API_KEY: ${{ secrets.HELO_API_KEY }} - HELO_BASE_URL: ${{ vars.HELO_BASE_URL }} - run: pytest --integration -m integration -q diff --git a/.gitignore b/.gitignore index 67af48a..9a4b6b8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,31 +1,13 @@ -# API spec -openapi.json - -# Python __pycache__/ *.py[cod] *.egg-info/ -*.egg -dist/ -build/ .eggs/ - -# Virtual environments +build/ +dist/ .venv/ venv/ -env/ - -# Testing +.mypy_cache/ .pytest_cache/ +.ruff_cache/ .coverage htmlcov/ - -# Type checking -.mypy_cache/ - -# IDE -.vscode/ -.idea/ - -# macOS -.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a0ba7d6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,54 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this +repository. + +## This repository is generated + +Every file here except `.git` is produced by the `helo-sdk-generator-python` gem, which lives in +the `helo-sdk-generator` repo (checked out next to this one) under +`vendor/gems/helo-sdk-generator-python`. Regenerating sweeps the working tree first, so **any edit +made here is lost on the next run**. Fix the generator's Ruby or ERB templates instead, then +regenerate: + +```bash +cd ../helo-sdk-generator && ./exe/helo-sdk-generator public +``` + +## Commands + +```bash +make install # pip install -e ".[dev]" +make check # ruff check . && mypy sdk_helo_email +make test # pytest (mocked HTTP, no server needed) +make build # build a wheel and sdist +``` + +## Architecture + +- `Helo` / `AsyncHelo` (`_client.py`) — the two facades. Each exposes one + resource per API tag and resolves the API key from `HELO_API_KEY` when not passed. +- `resources/.py` — a sync and an async resource class per tag, generated from the same + operation list so the two stay in step. +- `_http.py` — httpx transport, retries with jittered backoff (honouring `Retry-After`), and the + status-code-to-exception mapping. `_exceptions.py` holds that hierarchy. +- `types/shared.py` — the `HeloModel` pydantic base plus every enum. `types/.py` holds + the response models a single tag owns; models two or more tags reach live in `shared`. +- `types/params.py` — TypedDicts for the object-shaped values request bodies accept. + +Request bodies are flattened into keyword arguments, so callers write +`client.sending.transactional(from_=..., to=[...])` rather than assembling a dict. `_utils.py` +converts those snake_case names to the API's camelCase and drops the ones left as `None`. + +## Documentation & code samples + +`docs/*.md` holds one file per tag. Each example is a fenced block whose info string is the +operation's OpenAPI `operationId`: + + ```python Activity_listEvents + ... + ``` + +`helo-sdk-generator` extracts those blocks and embeds them into the published OpenAPI description +as the Python `x-codeSamples` entry for that operation, so the info string must stay an exact +`operationId`. diff --git a/Makefile b/Makefile index d670712..47046b1 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,20 @@ -rm-dist: +.PHONY: install check test build clean + +install: + pip install -e ".[dev]" + +check: + ruff check . + mypy sdk_helo_email + +test: + pytest -q + +clean: rm -rf dist -build: rm-dist +build: clean python3 -m build -publish-test: build - python3 -m twine upload --repository testpypi --verbose dist/* - -install-test: - pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ --upgrade --no-cache-dir sdk_helo_email +publish: build + python3 -m twine upload dist/* diff --git a/README.md b/README.md index 80780c2..f77fae3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Helo Python SDK -Python client library for the [Helo](https://helohq.com) email API. +Helo API ## Installation @@ -10,371 +10,100 @@ pip install sdk_helo_email Requires Python 3.10+ and installs `httpx` and `pydantic` automatically. -## Quick Start +## Quick start ```python -import sdk_helo_email +import sdk_helo_email as helo -# API key is read from the HELO_API_KEY environment variable automatically -client = sdk_helo_email.Helo() +# The API key is read from HELO_API_KEY when not passed explicitly. +client = helo.Helo() -response = client.sending.transactional( - from_={"email": "sender@example.com", "name": "Acme"}, - to=[{"email": "user@example.com", "name": "Alice"}], - subject="Hello from Helo", - html="

Welcome!

", - text="Welcome!", +paginated_events = client.activity.list_events( + channel_id="550e8400-e29b-41d4-a716-446655440000", + message_id="550e8400-e29b-41d4-a716-446655440000", ) -print(response.message_id) ``` -## Authentication - -Set the `HELO_API_KEY` environment variable and the client picks it up automatically: - -```bash -export HELO_API_KEY="your-api-key" -``` - -```python -client = sdk_helo_email.Helo() -``` - -You can also pass the key explicitly — useful when managing multiple accounts or reading from a secrets manager: +Pass the key directly when you manage several accounts or read it from a secrets manager: ```python -client = sdk_helo_email.Helo(api_key="your-api-key") +client = helo.Helo(api_key="your-api-key") ``` -Optionally override the base URL, timeout, or retry count (defaults: `https://api.helohq.com`, 30 seconds, 2 retries): +Override the base URL, timeout, or retry count (defaults: `https://api.helohq.com`, 30 seconds, +2 retries): ```python -client = sdk_helo_email.Helo( +client = helo.Helo( base_url="https://api.helohq.com", timeout=60.0, max_retries=2, ) ``` -## Context Manager - -Use `with` to ensure the underlying HTTP connection is closed: +Use `with` so the underlying connection pool is closed: ```python -with sdk_helo_email.Helo() as client: - client.sending.transactional(...) +with helo.Helo() as client: + ... ``` ## Async -An `AsyncHelo` client mirrors the synchronous API; every resource method becomes -a coroutine. Use it as an async context manager so the connection pool is closed: +`AsyncHelo` mirrors the synchronous API; every resource method becomes a coroutine. ```python import asyncio -import sdk_helo_email - -async def main(): - async with sdk_helo_email.AsyncHelo() as client: - response = await client.sending.transactional( - from_={"email": "sender@example.com"}, - to=[{"email": "user@example.com"}], - subject="Hello", - text="Hi!", - ) - print(response.message_id) - -asyncio.run(main()) -``` - -## Automatic Retries - -Requests that fail with a transient error — connection errors, timeouts, `429`, -and `5xx` responses — are retried automatically with exponential backoff. When the -server sends a `Retry-After` header (such as on a `429`), it is honored. Control -the number of retries with `max_retries` (set to `0` to disable): - -```python -client = sdk_helo_email.Helo(max_retries=5) -``` - -## Sending - -### Transactional email - -```python -response = client.sending.transactional( - from_={"email": "sender@example.com"}, - to=[{"email": "user@example.com"}], - subject="Your order shipped", - html="

Your order is on its way.

", - text="Your order is on its way.", - # Optional - cc=[{"email": "support@example.com"}], - bcc=[{"email": "archive@example.com"}], - reply_to=[{"email": "noreply@example.com"}], - tags=["order", "shipping"], - metadata={"order_id": "12345"}, - channel_id="ch_abc123", - idempotency_key="order-12345-shipped", -) -``` - -### Transactional batch - -```python -response = client.sending.transactional_batch( - requests=[ - { - "from": {"email": "sender@example.com"}, - "to": [{"email": "user1@example.com"}], - "subject": "Hello Alice", - "text": "Hi Alice!", - }, - { - "from": {"email": "sender@example.com"}, - "to": [{"email": "user2@example.com"}], - "subject": "Hello Bob", - "text": "Hi Bob!", - }, - ], - channel_id="ch_abc123", -) -``` - -### Broadcast (template-driven, multi-recipient) - -```python -response = client.sending.broadcast( - from_={"email": "newsletter@example.com"}, - template={"id": "tmpl_abc123"}, - messages=[ - {"to": [{"email": "user1@example.com"}], "variables": {"name": "Alice"}}, - {"to": [{"email": "user2@example.com"}], "variables": {"name": "Bob"}}, - ], - channel_id="ch_abc123", -) -``` - -### Broadcast message (single recipient) - -```python -response = client.sending.broadcast_message( - from_={"email": "newsletter@example.com"}, - to=[{"email": "user@example.com"}], - subject="This month's digest", - html="

Here's what's new...

", - channel_id="ch_abc123", -) -``` - -## Channels - -```python -# Create -channel = client.channels.create( - name="Production", - delivery_type=sdk_helo_email.DeliveryType.LIVE, -) - -# List -channels = client.channels.list(limit=20, offset=0) - -# Retrieve -channel = client.channels.retrieve("ch_abc123") - -# Update -channel = client.channels.update("ch_abc123", name="Production v2") - -# Delete -client.channels.delete("ch_abc123") -``` - -**`DeliveryType` values:** `LIVE`, `SANDBOX` - -## Activity - -```python -# List events -events = client.activity.list_events( - channel_id="ch_abc123", - start_date="2024-01-01", - end_date="2024-01-31", - event_types=[sdk_helo_email.EventType.DELIVERED, sdk_helo_email.EventType.OPENED], - limit=50, -) - -# List messages -messages = client.activity.list_messages( - channel_id="ch_abc123", - recipient="user@example.com", - limit=25, -) - -# Retrieve a message -message = client.activity.retrieve_message("msg_abc123") -``` - -**`EventType` values:** `DELIVERED`, `OPENED`, `CLICKED`, `BOUNCED`, `COMPLAINED`, `UNSUBSCRIBED` - -**`MailType` values:** `TRANSACTIONAL`, `BROADCAST` - -## Domains - -```python -# Create -domain = client.domains.create(name="mail.example.com", channel_ids=["ch_abc123"]) - -# List -domains = client.domains.list(limit=10) - -# Retrieve -domain = client.domains.retrieve("dom_abc123") - -# Update (assign/reassign channels) -domain = client.domains.update("dom_abc123", channel_ids=["ch_abc123", "ch_xyz456"]) - -# Verify DNS records -dns_records = client.domains.verify("dom_abc123") - -# Rotate DKIM key -dns_record = client.domains.rotate_key("dom_abc123") - -# Delete -client.domains.delete("dom_abc123") -``` - -## Broadcasts - -```python -# List broadcasts for a channel -broadcasts = client.broadcasts.list( - channel_id="ch_abc123", - status=sdk_helo_email.BroadcastStatus.SENT, -) - -# Retrieve -broadcast = client.broadcasts.retrieve("brd_abc123") - -# List delivery failures -failures = client.broadcasts.list_failures("brd_abc123") - -# List suppressions generated by this broadcast -suppressions = client.broadcasts.list_suppressions("brd_abc123") -``` - -**`BroadcastStatus` values:** `PENDING`, `SENDING`, `SENT`, `FAILED` - -## Statistics - -```python -# Totals for a date range -totals = client.statistics.retrieve_totals( - from_="2024-01-01", - to="2024-01-31", - channel_id="ch_abc123", -) - -# Daily breakdown (timezone required) -daily = client.statistics.retrieve_daily( - from_="2024-01-01", - to="2024-01-31", - timezone="America/New_York", - channel_id="ch_abc123", -) +import sdk_helo_email as helo -# Hourly breakdown -hourly = client.statistics.retrieve_hourly( - from_="2024-01-15T00:00:00Z", - to="2024-01-15T23:59:59Z", - channel_id="ch_abc123", -) -``` -## Suppressions - -```python -# List -suppressions = client.suppressions.list( - channel_id="ch_abc123", - mail_type=sdk_helo_email.MailType.TRANSACTIONAL, - reason=sdk_helo_email.SuppressionReason.BOUNCED, -) +async def main() -> None: + async with helo.AsyncHelo() as client: + paginated_events = await client.activity.list_events( + channel_id="550e8400-e29b-41d4-a716-446655440000", + message_id="550e8400-e29b-41d4-a716-446655440000", + ) -# Add suppressions -result = client.suppressions.create( - channel_id="ch_abc123", - mail_type=sdk_helo_email.MailType.TRANSACTIONAL, - emails=["bad@example.com", "invalid@example.com"], -) -# Remove suppressions -result = client.suppressions.remove( - channel_id="ch_abc123", - mail_type=sdk_helo_email.MailType.TRANSACTIONAL, - emails=["reactivated@example.com"], -) +asyncio.run(main()) ``` -**`SuppressionReason` values:** `BOUNCED`, `COMPLAINED`, `UNSUBSCRIBED`, `MANUAL` - -## Webhook Endpoints - -```python -# Create -endpoint = client.webhook_endpoints.create( - url="https://example.com/webhooks/helo", - events=[sdk_helo_email.WebhookEvent.DELIVERED, sdk_helo_email.WebhookEvent.BOUNCED], - channel_id="ch_abc123", -) - -# List -endpoints = client.webhook_endpoints.list(channel_ids=["ch_abc123"]) +## Automatic retries -# Retrieve -endpoint = client.webhook_endpoints.retrieve("whe_abc123") +Connection errors, timeouts, `429`, and `5xx` responses are retried with exponential backoff and +full jitter. A `Retry-After` header is honoured when present. Set `max_retries=0` to disable. -# Update -endpoint = client.webhook_endpoints.update( - "whe_abc123", - enabled=False, -) - -# Regenerate signing key -endpoint = client.webhook_endpoints.regenerate_signing_key("whe_abc123") - -# Delete -client.webhook_endpoints.delete("whe_abc123") -``` +## Resources -**`WebhookEvent` values:** `DELIVERED`, `OPENED`, `CLICKED`, `BOUNCED`, `COMPLAINED`, `UNSUBSCRIBED` +| Attribute | Documentation | +| --------- | ------------- | +| `client.activity` | [docs/Activity.md](docs/Activity.md) | +| `client.broadcasts` | [docs/Broadcasts.md](docs/Broadcasts.md) | +| `client.channels` | [docs/Channels.md](docs/Channels.md) | +| `client.domains` | [docs/Domains.md](docs/Domains.md) | +| `client.sending` | [docs/Sending.md](docs/Sending.md) | +| `client.statistics` | [docs/Statistics.md](docs/Statistics.md) | +| `client.suppressions` | [docs/Suppressions.md](docs/Suppressions.md) | +| `client.webhooks` | [docs/Webhooks.md](docs/Webhooks.md) | -## Error Handling +## Error handling -All API errors raise exceptions from the `sdk_helo_email` namespace: +Every API error subclasses `APIError` and carries `.status_code`, `.error_code`, `.detail`, and +`.request_id`. `RateLimitError` also exposes `.retry_after`. ```python -import sdk_helo_email +import sdk_helo_email as helo try: - client.sending.transactional( - from_={"email": "sender@example.com"}, - to=[{"email": "user@example.com"}], - subject="Test", - ) -except sdk_helo_email.AuthenticationError as e: - print(f"Auth failed: {e} (status {e.status_code})") -except sdk_helo_email.BadRequestError as e: - print(f"Bad request: {e.detail}") -except sdk_helo_email.NotFoundError: - print("Resource not found") -except sdk_helo_email.APIError as e: - # Catch-all for any other API error - print(f"API error {e.status_code}: {e}") + paginated_events = client.activity.list_events( +except helo.AuthenticationError as exc: + print(f"auth failed: {exc} (status {exc.status_code})") +except helo.APIError as exc: + print(f"API error {exc.status_code}: {exc}") ``` -| Exception | HTTP Status | -|-----------|-------------| +| Exception | HTTP status | +| --------- | ----------- | | `BadRequestError` | 400 | | `AuthenticationError` | 401 | | `PermissionDeniedError` | 403 | @@ -384,46 +113,13 @@ except sdk_helo_email.APIError as e: | `RateLimitError` | 429 | | `InternalServerError` | 5xx | -All API errors subclass `APIError` and expose `.status_code`, `.error_code`, `.detail`, -and `.request_id`. `RateLimitError` additionally exposes `.retry_after` (seconds, when -the server provides it). - -Network-level failures (after retries are exhausted) raise `APIConnectionError`, or -`APITimeoutError` for timeouts. Both subclass `HeloError` — the base class for every -exception this library raises — so `except sdk_helo_email.HeloError` catches everything. +Network failures raise `APIConnectionError` (or `APITimeoutError`) once retries are exhausted. +Both subclass `HeloError`, the base of every exception this library raises. ## Development -Install the dev dependencies and run the checks: - -```bash -pip install -e ".[dev]" -ruff check . -mypy sdk_helo_email -pytest -``` - -### Tests - -The default `pytest` run uses mocked HTTP responses — it's fast, needs no -credentials, and is what the main CI workflow runs. - -Integration tests (marked `@pytest.mark.integration`) exercise the **full** API -surface — creating, mutating, and deleting real resources, sending messages, and -reading back activity and statistics — and are **skipped by default**. They -target a local instance at `http://localhost:8000` by default and need an API -key. Run them with the `--integration` flag: - ```bash -export HELO_API_KEY="your-api-key" -# Optionally point at a different environment (default: http://localhost:8000): -# export HELO_BASE_URL="https://staging.api.helohq.com" -pytest --integration +make install +make check +make test ``` - -Created resources use unique names and are cleaned up automatically. Without -`HELO_API_KEY` set, the integration tests skip. - -In CI they run via a separate, manually-triggered `Integration` workflow, so -pushes and PRs stay fast. Point it at a reachable API by setting the -`HELO_BASE_URL` repository variable and the `HELO_API_KEY` secret. diff --git a/docs/Activity.md b/docs/Activity.md index 3e99c6b..4cb80bf 100644 --- a/docs/Activity.md +++ b/docs/Activity.md @@ -1,39 +1,59 @@ # Activity -Query message activity and events. +Track and retrieve message activity, including messages, delivery and engagement events. -## List events +| Method | HTTP request | Description | +| ------ | ------------ | ----------- | +| [**activity.list_events**](#list_events) | **GET** /activity/events | List activity events | +| [**activity.list_messages**](#list_messages) | **GET** /activity/messages | List messages | +| [**activity.retrieve_message**](#retrieve_message) | **GET** /activity/messages/{id} | Retrieve message details | + +## list_events `GET /activity/events` +Retrieves activity events for messages, including delivery status, opens, clicks, bounces, unsubscribes and complaints. + ```python Activity_listEvents import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -events = client.activity.list_events(start_date="2024-01-01", limit=50) +paginated_events = client.activity.list_events( + channel_id="550e8400-e29b-41d4-a716-446655440000", + message_id="550e8400-e29b-41d4-a716-446655440000", +) ``` -## List messages +## list_messages `GET /activity/messages` +Retrieves a paginated list of sent messages with basic tracking information. + ```python Activity_listMessages import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -messages = client.activity.list_messages(recipient="customer@example.com", limit=50) +paginated_messages = client.activity.list_messages( + channel_id="550e8400-e29b-41d4-a716-446655440000", + after=10, +) ``` -## Retrieve a message +## retrieve_message `GET /activity/messages/{id}` +Fetches detailed tracking information for a specific message, including all associated events. + ```python Activity_retrieveMessage import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -message = client.activity.retrieve_message("message-id") +message_details = client.activity.retrieve_message( + "550e8400-e29b-41d4-a716-446655440000", +) ``` diff --git a/docs/Broadcasts.md b/docs/Broadcasts.md index e645acf..5ebefd3 100644 --- a/docs/Broadcasts.md +++ b/docs/Broadcasts.md @@ -1,54 +1,78 @@ # Broadcasts -Query broadcast campaigns. +Manage and track broadcast email campaigns. -## List broadcasts +| Method | HTTP request | Description | +| ------ | ------------ | ----------- | +| [**broadcasts.list**](#list) | **GET** /broadcasts | List broadcasts | +| [**broadcasts.retrieve**](#retrieve) | **GET** /broadcasts/{id} | Retrieve a broadcast | +| [**broadcasts.list_failures**](#list_failures) | **GET** /broadcasts/{id}/failures | List failed broadcast messages | +| [**broadcasts.list_suppressions**](#list_suppressions) | **GET** /broadcasts/{id}/suppressions | List broadcast suppressed recipients | + +## list `GET /broadcasts` +Retrieves a paginated list of sent broadcasts with summary statistics. + ```python Broadcasts_list import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -broadcasts = client.broadcasts.list( - channel_id="channel-id", - status=helo.BroadcastStatus.COMPLETED, +paginated_response_of_broadcast = client.broadcasts.list( + channel_id="550e8400-e29b-41d4-a716-446655440000", + status=helo.BroadcastStatus.ACCEPTED, + subject="test-subject", ) ``` -## Retrieve a broadcast +## retrieve `GET /broadcasts/{id}` +Fetches details and statistics for a specific broadcast. + ```python Broadcasts_retrieve import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -broadcast = client.broadcasts.retrieve("broadcast-id") +broadcast_details = client.broadcasts.retrieve("550e8400-e29b-41d4-a716-446655440000") ``` -## List a broadcast's failures +## list_failures `GET /broadcasts/{id}/failures` +Returns messages that could not be delivered due to permanent errors (e.g. invalid addresses, domain issues). Transient errors that were retried successfully do not appear here. + ```python Broadcasts_listFailures import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -failures = client.broadcasts.list_failures("broadcast-id") +paginated_response_of_broadcast_failure = client.broadcasts.list_failures( + "550e8400-e29b-41d4-a716-446655440000", + limit=10, + offset=10, +) ``` -## List a broadcast's suppressions +## list_suppressions `GET /broadcasts/{id}/suppressions` +Returns recipients that were skipped because they appear on a suppression list (e.g. previous bounces or unsubscribes). + ```python Broadcasts_listSuppressions import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -suppressions = client.broadcasts.list_suppressions("broadcast-id") +paginated_response_of_broadcast_suppression = client.broadcasts.list_suppressions( + "550e8400-e29b-41d4-a716-446655440000", + limit=10, + offset=10, +) ``` diff --git a/docs/Channels.md b/docs/Channels.md index 1b6519f..4f14d30 100644 --- a/docs/Channels.md +++ b/docs/Channels.md @@ -1,67 +1,90 @@ # Channels -Manage sending channels. +Create and manage communication channels for organizing messages. -## Create a channel +| Method | HTTP request | Description | +| ------ | ------------ | ----------- | +| [**channels.list**](#list) | **GET** /channels | List all channels | +| [**channels.create**](#create) | **POST** /channels | Create a channel | +| [**channels.retrieve**](#retrieve) | **GET** /channels/{id} | Retrieve a channel | +| [**channels.update**](#update) | **PATCH** /channels/{id} | Update a channel | +| [**channels.delete**](#delete) | **DELETE** /channels/{id} | Delete a channel | -`POST /channels` +## list -```python Channels_create +`GET /channels` + +Retrieves a list of all channels accessible to the current user. + +```python Channels_list import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -channel = client.channels.create( - name="Transactional", - delivery_type=helo.DeliveryType.LIVE, - tracking={"links": True, "opens": True}, -) +pagination_result_of_channel_basic = client.channels.list(limit=10, offset=10) ``` -## List channels +## create -`GET /channels` +`POST /channels` -```python Channels_list +Creates a new communication channel for organizing and routing messages. + +```python Channels_create import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -channels = client.channels.list(limit=20, delivery_type=helo.DeliveryType.LIVE) +channel_details = client.channels.create( + name="test-name", + delivery_type=helo.DeliveryType.LIVE, + tracking={"links": True, "opens": True}, +) ``` -## Retrieve a channel +## retrieve `GET /channels/{id}` +Fetches the details and configuration of a specific channel. + ```python Channels_retrieve import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -channel = client.channels.retrieve("channel-id") +channel_details = client.channels.retrieve("550e8400-e29b-41d4-a716-446655440000") ``` -## Update a channel +## update `PATCH /channels/{id}` +Modifies an existing channel by ID. + ```python Channels_update import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -channel = client.channels.update("channel-id", name="Marketing") +channel_details = client.channels.update( + "550e8400-e29b-41d4-a716-446655440000", + name="test-name", + delivery_type=helo.DeliveryType.LIVE, + tracking={"links": True, "opens": True}, +) ``` -## Delete a channel +## delete `DELETE /channels/{id}` +Permanently removes a channel and all associated data. + ```python Channels_delete import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -client.channels.delete("channel-id") +client.channels.delete("550e8400-e29b-41d4-a716-446655440000") ``` diff --git a/docs/Domains.md b/docs/Domains.md index 305a047..859f5cc 100644 --- a/docs/Domains.md +++ b/docs/Domains.md @@ -1,87 +1,117 @@ # Domains -Manage sending domains and their DNS records. +Register, verify, and manage domains for email sending. -## Create a domain +| Method | HTTP request | Description | +| ------ | ------------ | ----------- | +| [**domains.list**](#list) | **GET** /domains | List all domains | +| [**domains.create**](#create) | **POST** /domains | Create a domain | +| [**domains.retrieve**](#retrieve) | **GET** /domains/{id} | Retrieve a domain | +| [**domains.update**](#update) | **PATCH** /domains/{id} | Update a domain | +| [**domains.delete**](#delete) | **DELETE** /domains/{id} | Delete a domain | +| [**domains.verify**](#verify) | **POST** /domains/{id}/verify | Verify a domain | +| [**domains.rotate_key**](#rotate_key) | **POST** /domains/{id}/rotate-key | Rotate a domain key | -`POST /domains` +## list -```python Domains_create +`GET /domains` + +Retrieves all domains associated with the current account, including their verification status. + +```python Domains_list import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -domain = client.domains.create(name="mail.example.com", channel_ids=["channel-id"]) +paginated_response_of_domain = client.domains.list(limit=10, offset=10) ``` -## List domains +## create -`GET /domains` +`POST /domains` -```python Domains_list +Registers a new domain for sending emails. The domain must be verified before it can be used. + +```python Domains_create import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -domains = client.domains.list(limit=20) +domain_with_dns = client.domains.create( + name="test-name", + channel_ids=["550e8400-e29b-41d4-a716-446655440000"], +) ``` -## Retrieve a domain +## retrieve `GET /domains/{id}` +Gets detailed information about a specific domain, including verification status and configuration. + ```python Domains_retrieve import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -domain = client.domains.retrieve("domain-id") +domain_with_dns = client.domains.retrieve("550e8400-e29b-41d4-a716-446655440000") ``` -## Update a domain +## update `PATCH /domains/{id}` +Modifies an existing domain by ID. + ```python Domains_update import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -domain = client.domains.update("domain-id", channel_ids=["channel-id"]) +domain = client.domains.update( + "550e8400-e29b-41d4-a716-446655440000", + channel_ids=["550e8400-e29b-41d4-a716-446655440000"], +) ``` -## Delete a domain +## delete `DELETE /domains/{id}` +Removes a domain from the account. This will stop all email sending from this domain. + ```python Domains_delete import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -client.domains.delete("domain-id") +client.domains.delete("550e8400-e29b-41d4-a716-446655440000") ``` -## Verify a domain +## verify `POST /domains/{id}/verify` +Initiates the domain verification process by checking DNS records. + ```python Domains_verify import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -dns_records = client.domains.verify("domain-id") +dns_records = client.domains.verify("550e8400-e29b-41d4-a716-446655440000") ``` -## Rotate a domain's DKIM key +## rotate_key `POST /domains/{id}/rotate-key` +Generates new DKIM keys for the domain. This is recommended for security best practices. + ```python Domains_rotateKey import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -dns_record = client.domains.rotate_key("domain-id") +dns_record = client.domains.rotate_key("550e8400-e29b-41d4-a716-446655440000") ``` diff --git a/docs/Sending.md b/docs/Sending.md index 84674fe..41c1ac2 100644 --- a/docs/Sending.md +++ b/docs/Sending.md @@ -1,91 +1,163 @@ # Sending -Send transactional and broadcast emails through the Helo API. +Send transactional and broadcast emails. -## Send a transactional email +| Method | HTTP request | Description | +| ------ | ------------ | ----------- | +| [**sending.transactional**](#transactional) | **POST** /send/transactional | Send a transactional email | +| [**sending.transactional_batch**](#transactional_batch) | **POST** /send/transactional/batch | Send transactional emails in batch | +| [**sending.broadcast**](#broadcast) | **POST** /send/broadcast | Send a broadcast email | +| [**sending.broadcast_message**](#broadcast_message) | **POST** /send/broadcast/message | Send a single broadcast email | + +## transactional `POST /send/transactional` +Sends a single transactional email such as receipts, confirmations, or notifications. + ```python Sending_transactional import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -response = client.sending.transactional( +send_message_accepted = client.sending.transactional( from_={"email": "from@yourdomain.com", "name": "From name"}, - to=[{"email": "to@example.com"}], + to=[{"email": "to@example.com", "name": "To name"}], + cc=[{"email": "cc@example.com", "name": "Cc name"}], + bcc=[{"email": "bcc@example.com", "name": "Bcc name"}], + reply_to=[{"email": "reply-to@example.com", "name": "Reply-To name"}], subject="Hello from Helo", html="

Hi there, new friend.

This is a test message, delivered with <3 by Helo.

", text="This is a test message, delivered with <3 by Helo.", + template={ + "subject": "test-subject", + "html": "test-html", + "text": "test-text", + "inlineStyles": True, + }, + tracking={"opens": True, "links": True}, + attachments=[ + { + "content": "SGVsbG8gd29ybGQ=", + "contentId": "test-contentId", + "contentType": "test-contentType", + "fileName": "test-fileName", + "disposition": helo.AttachmentDisposition.ATTACHMENT, + }, + ], tags=["welcome", "onboarding"], - channel_id="your-channel-id", + channel_id="550e8400-e29b-41d4-a716-446655440000", + idempotency_key="test-idempotency_key", ) ``` -## Send transactional emails in batch +## transactional_batch `POST /send/transactional/batch` +Sends multiple transactional emails in a single API request for better performance. + ```python Sending_transactionalBatch import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -response = client.sending.transactional_batch( +send_message_batch = client.sending.transactional_batch( requests=[ { "from": {"email": "from@yourdomain.com", "name": "From name"}, - "to": [{"email": "first@example.com"}], + "to": [{"email": "to@example.com", "name": "To name"}], "subject": "Hello from Helo", "html": "

Hi there, new friend.

This is a test message, delivered with <3 by Helo.

", - "text": "This is a test message, delivered with <3 by Helo." + "text": "This is a test message, delivered with <3 by Helo.", + "tags": ["welcome", "onboarding"], }, - { - "from": {"email": "from@yourdomain.com", "name": "From name"}, - "to": [{"email": "second@example.com"}], - "subject": "Hello from Helo", - "html": "

Hi there, new friend.

This is a test message, delivered with <3 by Helo.

", - "text": "This is a test message, delivered with <3 by Helo." - } ], - channel_id="your-channel-id", + channel_id="550e8400-e29b-41d4-a716-446655440000", + idempotency_key="test-idempotency_key", ) ``` -## Send a broadcast email +## broadcast `POST /send/broadcast` +Sends a broadcast email to multiple recipients for marketing or announcement purposes. + ```python Sending_broadcast import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -response = client.sending.broadcast( - from_={"email": "from@yourdomain.com", "name": "From name"}, - template={"subject": "Product update", "html": "

Here's what's new this month.

"}, +send_broadcast = client.sending.broadcast( + from_={"email": "test@example.com", "name": "test-name"}, + template={ + "subject": "test-subject", + "html": "test-html", + "text": "test-text", + "inlineStyles": True, + }, messages=[ - {"to": [{"email": "first@example.com"}]}, - {"to": [{"email": "second@example.com"}]}, + { + "to": [{"email": "test@example.com", "name": "test-name"}], + "tags": ["test-tag"], + }, + ], + reply_to=[{"email": "test@example.com", "name": "test-name"}], + tracking={"opens": True, "links": True}, + attachments=[ + { + "content": "SGVsbG8gd29ybGQ=", + "contentId": "test-contentId", + "contentType": "test-contentType", + "fileName": "test-fileName", + "disposition": helo.AttachmentDisposition.ATTACHMENT, + }, ], - channel_id="your-channel-id", + tags=["test-tag"], + channel_id="550e8400-e29b-41d4-a716-446655440000", + idempotency_key="test-idempotency_key", ) ``` -## Send a single broadcast email +## broadcast_message `POST /send/broadcast/message` +Sends a single broadcast email message. + ```python Sending_broadcastMessage import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -response = client.sending.broadcast_message( +send_message_accepted = client.sending.broadcast_message( from_={"email": "from@yourdomain.com", "name": "From name"}, - to=[{"email": "to@example.com"}], + to=[{"email": "to@example.com", "name": "To name"}], + cc=[{"email": "cc@example.com", "name": "Cc name"}], + bcc=[{"email": "bcc@example.com", "name": "Bcc name"}], + reply_to=[{"email": "reply-to@example.com", "name": "Reply-To name"}], subject="Hello from Helo", html="

Hi there, new friend.

This is a test message, delivered with <3 by Helo.

", - channel_id="your-channel-id", + text="This is a test message, delivered with <3 by Helo.", + template={ + "subject": "test-subject", + "html": "test-html", + "text": "test-text", + "inlineStyles": True, + }, + tracking={"opens": True, "links": True}, + attachments=[ + { + "content": "SGVsbG8gd29ybGQ=", + "contentId": "test-contentId", + "contentType": "test-contentType", + "fileName": "test-fileName", + "disposition": helo.AttachmentDisposition.ATTACHMENT, + }, + ], + tags=["welcome", "onboarding"], + channel_id="550e8400-e29b-41d4-a716-446655440000", + idempotency_key="test-idempotency_key", ) ``` diff --git a/docs/Statistics.md b/docs/Statistics.md index 2a19887..d95c2cc 100644 --- a/docs/Statistics.md +++ b/docs/Statistics.md @@ -1,43 +1,64 @@ # Statistics -Retrieve delivery statistics. `from_` and `to` are ISO-8601 date strings. +Access activity statistics. -## Hourly statistics +| Method | HTTP request | Description | +| ------ | ------------ | ----------- | +| [**statistics.retrieve_hourly**](#retrieve_hourly) | **GET** /statistics/hourly | Retrieve hourly statistics | +| [**statistics.retrieve_daily**](#retrieve_daily) | **GET** /statistics/daily | Retrieve daily statistics | +| [**statistics.retrieve_totals**](#retrieve_totals) | **GET** /statistics/totals | Retrieve all time statistics | + +## retrieve_hourly `GET /statistics/hourly` +Fetches hourly aggregated statistics. + ```python Statistics_retrieveHourly import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -stats = client.statistics.retrieve_hourly(from_="2024-01-01", to="2024-01-02") +statistics_hourly = client.statistics.retrieve_hourly( + from_="2024-01-01T00:00:00Z", + to="2024-01-01T00:00:00Z", + channel_id="550e8400-e29b-41d4-a716-446655440000", +) ``` -## Daily statistics +## retrieve_daily `GET /statistics/daily` +Fetches daily aggregated statistics. + ```python Statistics_retrieveDaily import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -stats = client.statistics.retrieve_daily( +statistics_daily = client.statistics.retrieve_daily( from_="2024-01-01", - to="2024-01-31", + to="2024-01-01", timezone="America/New_York", + channel_id="550e8400-e29b-41d4-a716-446655440000", ) ``` -## Total statistics +## retrieve_totals `GET /statistics/totals` +Fetches cumulative statistics. + ```python Statistics_retrieveTotals import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -stats = client.statistics.retrieve_totals(from_="2024-01-01", to="2024-01-31") +statistics_totals = client.statistics.retrieve_totals( + from_="2024-01-01T00:00:00Z", + to="2024-01-01T00:00:00Z", + channel_id="550e8400-e29b-41d4-a716-446655440000", +) ``` diff --git a/docs/Suppressions.md b/docs/Suppressions.md index df070c1..16ee65f 100644 --- a/docs/Suppressions.md +++ b/docs/Suppressions.md @@ -1,50 +1,62 @@ # Suppressions -Manage suppressed recipients. +| Method | HTTP request | Description | +| ------ | ------------ | ----------- | +| [**suppressions.list**](#list) | **GET** /suppressions | List suppressions | +| [**suppressions.create**](#create) | **POST** /suppressions | Create suppressions | +| [**suppressions.remove**](#remove) | **POST** /suppressions/remove | Remove suppressions | -## List suppressions +## list `GET /suppressions` +Retrieves a list of suppressed email addresses for a channel. + ```python Suppressions_list import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -suppressions = client.suppressions.list( - channel_id="channel-id", +paginated_response_of_suppression = client.suppressions.list( + channel_id="550e8400-e29b-41d4-a716-446655440000", mail_type=helo.MailType.TRANSACTIONAL, + reason=helo.SuppressionReason.BOUNCE, + email="test@example.com", ) ``` -## Add suppressions +## create `POST /suppressions` +Adds email addresses to the suppression list to prevent future sends. + ```python Suppressions_create import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -result = client.suppressions.create( - channel_id="channel-id", +create_suppressions = client.suppressions.create( + channel_id="550e8400-e29b-41d4-a716-446655440000", mail_type=helo.MailType.TRANSACTIONAL, - emails=["blocked@example.com"], + emails=["test@example.com"], ) ``` -## Remove suppressions +## remove `POST /suppressions/remove` +Removes email addresses from the suppression list to allow future sends. + ```python Suppressions_remove import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -result = client.suppressions.remove( - channel_id="channel-id", +remove_suppressions = client.suppressions.remove( + channel_id="550e8400-e29b-41d4-a716-446655440000", mail_type=helo.MailType.TRANSACTIONAL, - emails=["blocked@example.com"], + emails=["test@example.com"], ) ``` diff --git a/docs/Webhooks.md b/docs/Webhooks.md index 92da9bd..b87d700 100644 --- a/docs/Webhooks.md +++ b/docs/Webhooks.md @@ -1,80 +1,109 @@ # Webhooks -Manage webhook endpoints. In the Python SDK these live under `client.webhook_endpoints`. +Create and manage webhooks for event notifications. -## Create a webhook endpoint +| Method | HTTP request | Description | +| ------ | ------------ | ----------- | +| [**webhooks.list**](#list) | **GET** /webhooks | List all webhooks | +| [**webhooks.create**](#create) | **POST** /webhooks | Create a webhook | +| [**webhooks.retrieve**](#retrieve) | **GET** /webhooks/{id} | Retrieve a webhook | +| [**webhooks.update**](#update) | **PATCH** /webhooks/{id} | Update a webhook | +| [**webhooks.delete**](#delete) | **DELETE** /webhooks/{id} | Delete a webhook | +| [**webhooks.regenerate_signing_key**](#regenerate_signing_key) | **POST** /webhooks/{id}/regenerate-signing-key | Regenerate webhook signing key | -`POST /webhooks` +## list -```python Webhooks_create +`GET /webhooks` + +Retrieves all webhooks configured for the account. + +```python Webhooks_list import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -webhook = client.webhook_endpoints.create( - url="https://example.com/webhooks/helo", - events=[helo.WebhookEvent.DELIVERED, helo.WebhookEvent.BOUNCED], - channel_id="channel-id", - enabled=True, -) +pagination_result_of_webhook = client.webhooks.list(limit=10, offset=10) ``` -## List webhook endpoints +## create -`GET /webhooks` +`POST /webhooks` -```python Webhooks_list +Registers a new webhook to receive event notifications. + +```python Webhooks_create import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -webhooks = client.webhook_endpoints.list(limit=20) +webhook = client.webhooks.create( + url="test-url", + events=[helo.WebhookEvent.ACCEPTED], + channel_id="550e8400-e29b-41d4-a716-446655440000", + additional_headers=[{"name": "test-name", "value": "test-value"}], + enabled=True, +) ``` -## Retrieve a webhook endpoint +## retrieve `GET /webhooks/{id}` +Fetches the details and configuration of a specific webhook. + ```python Webhooks_retrieve import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -webhook = client.webhook_endpoints.retrieve("webhook-id") +webhook = client.webhooks.retrieve("550e8400-e29b-41d4-a716-446655440000") ``` -## Update a webhook endpoint +## update `PATCH /webhooks/{id}` +Modifies an existing webhook by ID. + ```python Webhooks_update import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -webhook = client.webhook_endpoints.update("webhook-id", enabled=False) +webhook = client.webhooks.update( + "550e8400-e29b-41d4-a716-446655440000", + url="test-url", + events=[helo.WebhookEvent.ACCEPTED], + channel_id="550e8400-e29b-41d4-a716-446655440000", + additional_headers=[{"name": "test-name", "value": "test-value"}], + enabled=True, +) ``` -## Delete a webhook endpoint +## delete `DELETE /webhooks/{id}` +Permanently removes a webhook. + ```python Webhooks_delete import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -client.webhook_endpoints.delete("webhook-id") +client.webhooks.delete("550e8400-e29b-41d4-a716-446655440000") ``` -## Regenerate a webhook signing key +## regenerate_signing_key `POST /webhooks/{id}/regenerate-signing-key` +Regenerate the signing key used for the webhook signature. This operation replaces the old key. + ```python Webhooks_regenerateSigningKey import sdk_helo_email as helo client = helo.Helo() # reads HELO_API_KEY from the environment -webhook = client.webhook_endpoints.regenerate_signing_key("webhook-id") +webhook = client.webhooks.regenerate_signing_key("550e8400-e29b-41d4-a716-446655440000") ``` diff --git a/pyproject.toml b/pyproject.toml index a025455..564f18c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,22 +5,17 @@ build-backend = "setuptools.build_meta" [project] name = "sdk_helo_email" dynamic = ["version"] -description = "Python SDK for the Helo Email API (https://helohq.com)." +description = "Helo API" readme = "README.md" requires-python = ">=3.10" license = {text = "MIT"} -keywords = ["helo", "email", "sdk", "api"] +keywords = ["helo", "sdk", "api"] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Libraries :: Python Modules", - "Topic :: Communications :: Email", ] dependencies = [ "httpx>=0.25.0", @@ -38,9 +33,9 @@ dev = [ ] [project.urls] -Homepage = "https://helohq.com" +Homepage = "https://api.helohq.com" Documentation = "https://docs.helohq.com" -Repository = "https://github.com/helo/helo-sdk-python" +Repository = "https://github.com/helo-email/helo-sdk-python" [tool.setuptools.dynamic] version = {attr = "sdk_helo_email._version.__version__"} @@ -58,12 +53,14 @@ line-length = 100 [tool.ruff.lint] select = ["E", "F", "I", "UP"] +# Generated tests embed the documentation samples verbatim, and a sample value +# such as an HTML body is one unbreakable string literal. +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["E501"] + [tool.mypy] python_version = "3.10" strict = true [tool.pytest.ini_options] asyncio_mode = "auto" -markers = [ - "integration: tests that hit the live Helo API (skipped unless --integration is passed)", -] diff --git a/sdk_helo_email/__init__.py b/sdk_helo_email/__init__.py index b075ad7..39b6eda 100644 --- a/sdk_helo_email/__init__.py +++ b/sdk_helo_email/__init__.py @@ -15,9 +15,16 @@ ) from ._version import __version__ from .types import ( + AcceptedWebhookPayload, ActivityEvent, + ActivityMailAddress, + Attachment, AttachmentDisposition, + BouncedDetails, + BouncedWebhookPayload, BroadcastContent, + BroadcastContentAttachment, + BroadcastContentTemplate, BroadcastDetailsResponse, BroadcastFailureResponse, BroadcastResponse, @@ -27,25 +34,45 @@ ChannelBasicResponse, ChannelDetailsResponse, ChannelTracking, + ClickedDetails, + ClickedWebhookPayload, + ClientDetails, + ComplainedDetails, + ComplainedWebhookPayload, + CreateChannelRequest, + CreateChannelTracking, + CreateDomainRequest, + CreateSuppressionsRequest, CreateSuppressionsResponse, - DailyStatisticsEntry, + CreateWebhookRequest, + DeliveredDetails, + DeliveredWebhookPayload, DeliveryStats, DeliveryType, + DeviceDetails, DnsRecordResponse, DnsRecordsResponse, DnsRecordStatus, DnsRecordType, DomainChannelResponse, + DomainKeyVerificationFailedPayload, + DomainKeyVerifiedPayload, DomainResponse, DomainWithDnsResponse, EventType, - HourlyStatisticsEntry, + HeloModel, MailAddress, + MailSource, MailType, Message, MessageDetailsResponse, - MessageEvent, - MessageTracking, + MessageDetailsResponseAttachment, + MessageDetailsResponseEvent, + MessageDetailsResponseTracking, + MessageStatistics, + MessageStatus, + OpenedDetails, + OpenedWebhookPayload, PaginatedEventsResponse, PaginatedMessagesResponse, PaginatedResponseOfBroadcast, @@ -54,28 +81,59 @@ PaginatedResponseOfDomainResponse, PaginatedResponseOfSuppressionResponse, PaginationResultOfChannelBasicResponse, - PaginationResultOfWebhookEndpointResponse, + PaginationResultOfWebhookResponse, + ProcessedWebhookPayload, RecipientHeaders, RemoveSuppressionResult, + RemoveSuppressionsRequest, RemoveSuppressionsResponse, + ResubscribedDetails, + ResubscribedWebhookPayload, + ReturnPathDomainVerificationFailedPayload, + ReturnPathDomainVerifiedPayload, + SendBroadcastRequest, + SendBroadcastRequestMessage, + SendBroadcastRequestTemplate, + SendBroadcastRequestTracking, SendBroadcastResponse, SendMessageAcceptedResponse, + SendMessageBatchRequest, SendMessageBatchResponse, SendMessageFailedResponse, + SendMessageRequest, + SendMessageRequestTemplate, + SendMessageRequestTracking, + SendMessageResponse, StatisticsDailyResponse, + StatisticsDailyResponseResult, StatisticsHourlyResponse, + StatisticsHourlyResponseResult, StatisticsTotalsResponse, SuppressionReason, SuppressionResponse, SuppressionResult, - WebhookEndpointResponse, + UnsubscribedDetails, + UnsubscribedWebhookPayload, + UpdateChannelRequest, + UpdateChannelTracking, + UpdateDomainRequest, + UpdateWebhookRequest, WebhookEvent, WebhookHeader, + WebhookLastResponse, + WebhookResponse, ) from .types.params import ( AttachmentParam, + CreateChannelTrackingParam, MailAddressParam, - TrackingParam, + SendBroadcastRequestMessageParam, + SendBroadcastRequestTemplateParam, + SendBroadcastRequestTrackingParam, + SendMessageRequestParam, + SendMessageRequestTemplateParam, + SendMessageRequestTrackingParam, + UpdateChannelTrackingParam, WebhookHeaderParam, ) @@ -83,88 +141,134 @@ "__version__", "Helo", "AsyncHelo", - # Exceptions - "HeloError", - "APIError", "APIConnectionError", + "APIError", "APITimeoutError", - "BadRequestError", "AuthenticationError", - "PermissionDeniedError", - "NotFoundError", + "BadRequestError", "ConflictError", - "UnprocessableEntityError", - "RateLimitError", + "HeloError", "InternalServerError", - # Enums + "NotFoundError", + "PermissionDeniedError", + "RateLimitError", + "UnprocessableEntityError", + "AcceptedWebhookPayload", + "ActivityEvent", + "ActivityMailAddress", + "Attachment", "AttachmentDisposition", + "BouncedDetails", + "BouncedWebhookPayload", + "BroadcastContent", + "BroadcastContentAttachment", + "BroadcastContentTemplate", + "BroadcastDetailsResponse", + "BroadcastFailureResponse", + "BroadcastResponse", + "BroadcastStatistics", "BroadcastStatus", + "BroadcastTracking", + "ChannelBasicResponse", + "ChannelDetailsResponse", + "ChannelTracking", + "ClickedDetails", + "ClickedWebhookPayload", + "ClientDetails", + "ComplainedDetails", + "ComplainedWebhookPayload", + "CreateChannelRequest", + "CreateChannelTracking", + "CreateDomainRequest", + "CreateSuppressionsRequest", + "CreateSuppressionsResponse", + "CreateWebhookRequest", + "DeliveredDetails", + "DeliveredWebhookPayload", + "DeliveryStats", "DeliveryType", + "DeviceDetails", + "DnsRecordResponse", "DnsRecordStatus", "DnsRecordType", + "DnsRecordsResponse", + "DomainChannelResponse", + "DomainKeyVerificationFailedPayload", + "DomainKeyVerifiedPayload", + "DomainResponse", + "DomainWithDnsResponse", "EventType", - "MailType", - "SuppressionReason", - "WebhookEvent", - # Request param types - "AttachmentParam", - "MailAddressParam", - "TrackingParam", - "WebhookHeaderParam", - # Shared types + "HeloModel", "MailAddress", - # Channel types - "ChannelBasicResponse", - "ChannelDetailsResponse", - "ChannelTracking", - "PaginationResultOfChannelBasicResponse", - # Activity types - "ActivityEvent", + "MailSource", + "MailType", "Message", "MessageDetailsResponse", - "MessageEvent", - "MessageTracking", + "MessageDetailsResponseAttachment", + "MessageDetailsResponseEvent", + "MessageDetailsResponseTracking", + "MessageStatistics", + "MessageStatus", + "OpenedDetails", + "OpenedWebhookPayload", "PaginatedEventsResponse", "PaginatedMessagesResponse", - # Domain types - "DnsRecordResponse", - "DnsRecordsResponse", - "DomainChannelResponse", - "DomainResponse", - "DomainWithDnsResponse", + "PaginatedResponseOfBroadcast", + "PaginatedResponseOfBroadcastFailure", + "PaginatedResponseOfBroadcastSuppression", "PaginatedResponseOfDomainResponse", - # Sending types + "PaginatedResponseOfSuppressionResponse", + "PaginationResultOfChannelBasicResponse", + "PaginationResultOfWebhookResponse", + "ProcessedWebhookPayload", + "RecipientHeaders", + "RemoveSuppressionResult", + "RemoveSuppressionsRequest", + "RemoveSuppressionsResponse", + "ResubscribedDetails", + "ResubscribedWebhookPayload", + "ReturnPathDomainVerificationFailedPayload", + "ReturnPathDomainVerifiedPayload", + "SendBroadcastRequest", + "SendBroadcastRequestMessage", + "SendBroadcastRequestTemplate", + "SendBroadcastRequestTracking", "SendBroadcastResponse", "SendMessageAcceptedResponse", + "SendMessageBatchRequest", "SendMessageBatchResponse", "SendMessageFailedResponse", - # Broadcast types - "BroadcastContent", - "BroadcastDetailsResponse", - "BroadcastFailureResponse", - "BroadcastResponse", - "BroadcastStatistics", - "BroadcastTracking", - "PaginatedResponseOfBroadcast", - "PaginatedResponseOfBroadcastFailure", - "PaginatedResponseOfBroadcastSuppression", - "RecipientHeaders", - # Statistics types - "DeliveryStats", - "DailyStatisticsEntry", - "HourlyStatisticsEntry", + "SendMessageRequest", + "SendMessageRequestTemplate", + "SendMessageRequestTracking", + "SendMessageResponse", "StatisticsDailyResponse", + "StatisticsDailyResponseResult", "StatisticsHourlyResponse", + "StatisticsHourlyResponseResult", "StatisticsTotalsResponse", - # Suppression types - "CreateSuppressionsResponse", - "PaginatedResponseOfSuppressionResponse", - "RemoveSuppressionsResponse", - "RemoveSuppressionResult", + "SuppressionReason", "SuppressionResponse", "SuppressionResult", - # Webhook types - "PaginationResultOfWebhookEndpointResponse", - "WebhookEndpointResponse", + "UnsubscribedDetails", + "UnsubscribedWebhookPayload", + "UpdateChannelRequest", + "UpdateChannelTracking", + "UpdateDomainRequest", + "UpdateWebhookRequest", + "WebhookEvent", "WebhookHeader", + "WebhookLastResponse", + "WebhookResponse", + "AttachmentParam", + "CreateChannelTrackingParam", + "MailAddressParam", + "SendBroadcastRequestMessageParam", + "SendBroadcastRequestTemplateParam", + "SendBroadcastRequestTrackingParam", + "SendMessageRequestParam", + "SendMessageRequestTemplateParam", + "SendMessageRequestTrackingParam", + "UpdateChannelTrackingParam", + "WebhookHeaderParam", ] diff --git a/sdk_helo_email/_client.py b/sdk_helo_email/_client.py index f35e387..1608ae0 100644 --- a/sdk_helo_email/_client.py +++ b/sdk_helo_email/_client.py @@ -19,36 +19,28 @@ AsyncSendingResource, AsyncStatisticsResource, AsyncSuppressionsResource, - AsyncWebhookEndpointsResource, + AsyncWebhooksResource, BroadcastsResource, ChannelsResource, DomainsResource, SendingResource, StatisticsResource, SuppressionsResource, - WebhookEndpointsResource, + WebhooksResource, ) def _resolve_api_key(api_key: str | None) -> str: - resolved_key = api_key or os.environ.get("HELO_API_KEY") - if not resolved_key: + resolved = api_key or os.environ.get("HELO_API_KEY") + if not resolved: raise ValueError( "No API key provided. Pass api_key= or set the HELO_API_KEY environment variable." ) - return resolved_key + return resolved class Helo: - """Synchronous client for the Helo API. - - Usage:: - - import helo - - client = helo.Helo(api_key="your-api-key") - channel = client.channels.create(name="my-channel", delivery_type=helo.DeliveryType.LIVE) - """ + """Synchronous client for the Helo API.""" activity: ActivityResource broadcasts: BroadcastsResource @@ -57,7 +49,7 @@ class Helo: sending: SendingResource statistics: StatisticsResource suppressions: SuppressionsResource - webhook_endpoints: WebhookEndpointsResource + webhooks: WebhooksResource def __init__( self, @@ -80,7 +72,7 @@ def __init__( self.sending = SendingResource(self._http) self.statistics = StatisticsResource(self._http) self.suppressions = SuppressionsResource(self._http) - self.webhook_endpoints = WebhookEndpointsResource(self._http) + self.webhooks = WebhooksResource(self._http) def close(self) -> None: self._http.close() @@ -93,21 +85,7 @@ def __exit__(self, *args: Any) -> None: class AsyncHelo: - """Asynchronous client for the Helo API. - - Usage:: - - import asyncio - import helo - - async def main(): - async with helo.AsyncHelo(api_key="your-api-key") as client: - channel = await client.channels.create( - name="my-channel", delivery_type=helo.DeliveryType.LIVE - ) - - asyncio.run(main()) - """ + """Asynchronous client for the Helo API.""" activity: AsyncActivityResource broadcasts: AsyncBroadcastsResource @@ -116,7 +94,7 @@ async def main(): sending: AsyncSendingResource statistics: AsyncStatisticsResource suppressions: AsyncSuppressionsResource - webhook_endpoints: AsyncWebhookEndpointsResource + webhooks: AsyncWebhooksResource def __init__( self, @@ -139,7 +117,7 @@ def __init__( self.sending = AsyncSendingResource(self._http) self.statistics = AsyncStatisticsResource(self._http) self.suppressions = AsyncSuppressionsResource(self._http) - self.webhook_endpoints = AsyncWebhookEndpointsResource(self._http) + self.webhooks = AsyncWebhooksResource(self._http) async def close(self) -> None: await self._http.aclose() diff --git a/sdk_helo_email/_exceptions.py b/sdk_helo_email/_exceptions.py index 9264a62..858a62e 100644 --- a/sdk_helo_email/_exceptions.py +++ b/sdk_helo_email/_exceptions.py @@ -8,7 +8,7 @@ class HeloError(Exception): class APIConnectionError(HeloError): - """Raised when the request could not reach the Helo API (network error, timeout).""" + """Raised when the request could not reach the API (network error, timeout).""" def __init__(self, message: str = "Connection error.") -> None: super().__init__(message) @@ -22,7 +22,7 @@ def __init__(self, message: str = "Request timed out.") -> None: class APIError(HeloError): - """Raised when the Helo API returns a non-success status code.""" + """Raised when the API returns a non-success status code.""" def __init__( self, diff --git a/sdk_helo_email/_http.py b/sdk_helo_email/_http.py index 9013169..33fd42e 100644 --- a/sdk_helo_email/_http.py +++ b/sdk_helo_email/_http.py @@ -48,8 +48,8 @@ def _default_headers(api_key: str) -> dict[str, str]: return { "Authorization": f"Bearer {api_key}", - "User-Agent": f"helo-python/{__version__}", - "Content-Type": "application/json" + "User-Agent": f"sdk-helo-email/{__version__}", + "Content-Type": "application/json", } @@ -84,17 +84,25 @@ def _backoff_delay(attempt: int, retry_after: float | None) -> float: return random.uniform(0, base) +def _error_message(data: dict[str, Any], status_code: int) -> str: + validation_errors = data.get("errors") + if validation_errors: + return ", ".join( + f"{field}: {problems[0]['message']}" + for field, problems in validation_errors.items() + if problems + ) + detail = data.get("detail") or data.get("title") + return str(detail) if detail else f"HTTP {status_code}" + + def _raise_api_error(response: httpx.Response) -> None: try: data: dict[str, Any] = response.json() except Exception: data = {} - validation_error_str = None - validation_errors = data.get("errors") - if validation_errors and len(validation_errors) > 0: - validation_error_str = ", ".join(f"{key}: {value[0]['message']}" for key, value in validation_errors.items()) - message = validation_error_str or data.get("detail") or data.get("title") or f"HTTP {response.status_code}" + message = _error_message(data, response.status_code) cls = _error_class(response.status_code) kwargs: dict[str, Any] = dict( status_code=response.status_code, @@ -104,8 +112,8 @@ def _raise_api_error(response: httpx.Response) -> None: response_data=data, ) if cls is RateLimitError: - raise RateLimitError(str(message), retry_after=_parse_retry_after(response), **kwargs) - raise cls(str(message), **kwargs) + raise RateLimitError(message, retry_after=_parse_retry_after(response), **kwargs) + raise cls(message, **kwargs) def _handle_response(response: httpx.Response) -> Any: @@ -184,22 +192,34 @@ def post( ) -> Any: return self._request("POST", path, json=json, params=params, headers=headers) + def put( + self, + path: str, + *, + json: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> Any: + return self._request("PUT", path, json=json, params=params, headers=headers) + def patch( self, path: str, *, json: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, headers: dict[str, str] | None = None, ) -> Any: - return self._request("PATCH", path, json=json, headers=headers) + return self._request("PATCH", path, json=json, params=params, headers=headers) def delete( self, path: str, *, + params: dict[str, Any] | None = None, headers: dict[str, str] | None = None, ) -> None: - self._request("DELETE", path, headers=headers) + self._request("DELETE", path, params=params, headers=headers) def close(self) -> None: self._client.close() @@ -277,22 +297,34 @@ async def post( ) -> Any: return await self._request("POST", path, json=json, params=params, headers=headers) + async def put( + self, + path: str, + *, + json: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> Any: + return await self._request("PUT", path, json=json, params=params, headers=headers) + async def patch( self, path: str, *, json: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, headers: dict[str, str] | None = None, ) -> Any: - return await self._request("PATCH", path, json=json, headers=headers) + return await self._request("PATCH", path, json=json, params=params, headers=headers) async def delete( self, path: str, *, + params: dict[str, Any] | None = None, headers: dict[str, str] | None = None, ) -> None: - await self._request("DELETE", path, headers=headers) + await self._request("DELETE", path, params=params, headers=headers) async def aclose(self) -> None: await self._client.aclose() diff --git a/sdk_helo_email/_utils.py b/sdk_helo_email/_utils.py index e6f9b8a..788f0f8 100644 --- a/sdk_helo_email/_utils.py +++ b/sdk_helo_email/_utils.py @@ -4,22 +4,25 @@ def to_camel(s: str) -> str: + """snake_case to camelCase, dropping the underscore keywords get escaped with.""" if s.endswith("_"): s = s[:-1] parts = s.split("_") return parts[0] + "".join(p.capitalize() for p in parts[1:]) -def build_params(**kwargs: Any) -> dict[str, Any]: - """Convert snake_case kwargs to camelCase, dropping None values.""" - return {to_camel(k): v for k, v in kwargs.items() if v is not None} +def build_params(**kwargs: Any) -> dict[str, Any] | None: + """Query parameters with camelCase keys, dropping None values.""" + params = {to_camel(k): v for k, v in kwargs.items() if v is not None} + return params or None def build_body(**kwargs: Any) -> dict[str, Any]: - """Build a request body dict with camelCase keys, dropping None values.""" - result: dict[str, Any] = {} - for k, v in kwargs.items(): - if v is not None: - camel_k = to_camel(k) - result[camel_k] = v - return result + """Request body with camelCase keys, dropping None values.""" + return {to_camel(k): v for k, v in kwargs.items() if v is not None} + + +def build_headers(**kwargs: Any) -> dict[str, str] | None: + """Request headers, dropping None values. Keys are passed through verbatim.""" + headers = {k: v for k, v in kwargs.items() if v is not None} + return headers or None diff --git a/sdk_helo_email/_version.py b/sdk_helo_email/_version.py index b91585f..f96eeb6 100644 --- a/sdk_helo_email/_version.py +++ b/sdk_helo_email/_version.py @@ -1 +1 @@ -__version__ = "1.0.0b3" +__version__ = "1.0.0b4" diff --git a/sdk_helo_email/resources/__init__.py b/sdk_helo_email/resources/__init__.py index cf23107..eec5333 100644 --- a/sdk_helo_email/resources/__init__.py +++ b/sdk_helo_email/resources/__init__.py @@ -5,7 +5,7 @@ from .sending import AsyncSendingResource, SendingResource from .statistics import AsyncStatisticsResource, StatisticsResource from .suppressions import AsyncSuppressionsResource, SuppressionsResource -from .webhook_endpoints import AsyncWebhookEndpointsResource, WebhookEndpointsResource +from .webhooks import AsyncWebhooksResource, WebhooksResource __all__ = [ "ActivityResource", @@ -22,6 +22,6 @@ "AsyncStatisticsResource", "SuppressionsResource", "AsyncSuppressionsResource", - "WebhookEndpointsResource", - "AsyncWebhookEndpointsResource", + "WebhooksResource", + "AsyncWebhooksResource", ] diff --git a/sdk_helo_email/resources/activity.py b/sdk_helo_email/resources/activity.py index 8e583f6..6631a9b 100644 --- a/sdk_helo_email/resources/activity.py +++ b/sdk_helo_email/resources/activity.py @@ -1,7 +1,6 @@ from __future__ import annotations from collections.abc import Sequence -from typing import Any from .._utils import build_params from ..types.activity import ( @@ -9,68 +8,10 @@ PaginatedEventsResponse, PaginatedMessagesResponse, ) -from ..types.shared import EventType, MailType +from ..types.shared import EventType, MailType, MessageStatus from ._base import AsyncBaseResource, BaseResource -def _events_params( - *, - channel_id: str | None, - message_id: str | None, - after: int | None, - start_date: str | None, - end_date: str | None, - limit: int | None, - recipient: str | None, - subject: str | None, - tags: Sequence[str] | None, - mail_type: MailType | None, - event_types: Sequence[EventType] | None, -) -> dict[str, Any] | None: - params = build_params( - channel_id=channel_id, - message_id=message_id, - after=after, - start_date=start_date, - end_date=end_date, - limit=limit, - recipient=recipient, - subject=subject, - tags=tags, - mail_type=mail_type.value if mail_type else None, - event_types=[e.value for e in event_types] if event_types else None, - ) - return params or None - - -def _messages_params( - *, - channel_id: str | None, - after: int | None, - start_date: str | None, - end_date: str | None, - limit: int | None, - recipient: str | None, - subject: str | None, - tags: Sequence[str] | None, - mail_type: MailType | None, - status: str | None, -) -> dict[str, Any] | None: - params = build_params( - channel_id=channel_id, - after=after, - start_date=start_date, - end_date=end_date, - limit=limit, - recipient=recipient, - subject=subject, - tags=tags, - mail_type=mail_type.value if mail_type else None, - status=status, - ) - return params or None - - class ActivityResource(BaseResource): def list_events( self, @@ -87,7 +28,9 @@ def list_events( mail_type: MailType | None = None, event_types: Sequence[EventType] | None = None, ) -> PaginatedEventsResponse: - params = _events_params( + """List activity events""" + + params = build_params( channel_id=channel_id, message_id=message_id, after=after, @@ -96,9 +39,9 @@ def list_events( limit=limit, recipient=recipient, subject=subject, - tags=tags, - mail_type=mail_type, - event_types=event_types, + tags=",".join(tags) if tags else None, + mail_type=mail_type.value if mail_type else None, + event_types=",".join([item.value for item in event_types]) if event_types else None, ) data = self._http.get("/activity/events", params=params) return PaginatedEventsResponse.model_validate(data) @@ -115,9 +58,11 @@ def list_messages( subject: str | None = None, tags: Sequence[str] | None = None, mail_type: MailType | None = None, - status: str | None = None, + status: MessageStatus | None = None, ) -> PaginatedMessagesResponse: - params = _messages_params( + """List messages""" + + params = build_params( channel_id=channel_id, after=after, start_date=start_date, @@ -125,14 +70,16 @@ def list_messages( limit=limit, recipient=recipient, subject=subject, - tags=tags, - mail_type=mail_type, - status=status, + tags=",".join(tags) if tags else None, + mail_type=mail_type.value if mail_type else None, + status=status.value if status else None, ) data = self._http.get("/activity/messages", params=params) return PaginatedMessagesResponse.model_validate(data) def retrieve_message(self, id: str) -> MessageDetailsResponse: + """Retrieve message details""" + data = self._http.get(f"/activity/messages/{id}") return MessageDetailsResponse.model_validate(data) @@ -153,7 +100,9 @@ async def list_events( mail_type: MailType | None = None, event_types: Sequence[EventType] | None = None, ) -> PaginatedEventsResponse: - params = _events_params( + """List activity events""" + + params = build_params( channel_id=channel_id, message_id=message_id, after=after, @@ -162,9 +111,9 @@ async def list_events( limit=limit, recipient=recipient, subject=subject, - tags=tags, - mail_type=mail_type, - event_types=event_types, + tags=",".join(tags) if tags else None, + mail_type=mail_type.value if mail_type else None, + event_types=",".join([item.value for item in event_types]) if event_types else None, ) data = await self._http.get("/activity/events", params=params) return PaginatedEventsResponse.model_validate(data) @@ -181,9 +130,11 @@ async def list_messages( subject: str | None = None, tags: Sequence[str] | None = None, mail_type: MailType | None = None, - status: str | None = None, + status: MessageStatus | None = None, ) -> PaginatedMessagesResponse: - params = _messages_params( + """List messages""" + + params = build_params( channel_id=channel_id, after=after, start_date=start_date, @@ -191,13 +142,15 @@ async def list_messages( limit=limit, recipient=recipient, subject=subject, - tags=tags, - mail_type=mail_type, - status=status, + tags=",".join(tags) if tags else None, + mail_type=mail_type.value if mail_type else None, + status=status.value if status else None, ) data = await self._http.get("/activity/messages", params=params) return PaginatedMessagesResponse.model_validate(data) async def retrieve_message(self, id: str) -> MessageDetailsResponse: + """Retrieve message details""" + data = await self._http.get(f"/activity/messages/{id}") return MessageDetailsResponse.model_validate(data) diff --git a/sdk_helo_email/resources/broadcasts.py b/sdk_helo_email/resources/broadcasts.py index b09566b..29e6b2a 100644 --- a/sdk_helo_email/resources/broadcasts.py +++ b/sdk_helo_email/resources/broadcasts.py @@ -1,7 +1,6 @@ from __future__ import annotations -from typing import Any - +from .._utils import build_params from ..types.broadcasts import ( BroadcastDetailsResponse, PaginatedResponseOfBroadcast, @@ -12,25 +11,6 @@ from ._base import AsyncBaseResource, BaseResource -def _list_params( - channel_id: str, - status: BroadcastStatus | None, - subject: str | None, - limit: int | None, - offset: int | None, -) -> dict[str, Any]: - params: dict[str, Any] = {"channelId": channel_id} - if status is not None: - params["status"] = status.value - if subject is not None: - params["subject"] = subject - if limit is not None: - params["limit"] = limit - if offset is not None: - params["offset"] = offset - return params - - class BroadcastsResource(BaseResource): def list( self, @@ -41,20 +21,46 @@ def list( limit: int | None = None, offset: int | None = None, ) -> PaginatedResponseOfBroadcast: - params = _list_params(channel_id, status, subject, limit, offset) + """List broadcasts""" + + params = build_params( + channel_id=channel_id, + status=status.value if status else None, + subject=subject, + limit=limit, + offset=offset, + ) data = self._http.get("/broadcasts", params=params) return PaginatedResponseOfBroadcast.model_validate(data) def retrieve(self, id: str) -> BroadcastDetailsResponse: + """Retrieve a broadcast""" + data = self._http.get(f"/broadcasts/{id}") return BroadcastDetailsResponse.model_validate(data) - def list_failures(self, id: str) -> PaginatedResponseOfBroadcastFailure: - data = self._http.get(f"/broadcasts/{id}/failures") + def list_failures( + self, id: str, *, limit: int | None = None, offset: int | None = None + ) -> PaginatedResponseOfBroadcastFailure: + """List failed broadcast messages""" + + params = build_params( + limit=limit, + offset=offset, + ) + data = self._http.get(f"/broadcasts/{id}/failures", params=params) return PaginatedResponseOfBroadcastFailure.model_validate(data) - def list_suppressions(self, id: str) -> PaginatedResponseOfBroadcastSuppression: - data = self._http.get(f"/broadcasts/{id}/suppressions") + def list_suppressions( + self, id: str, *, limit: int | None = None, offset: int | None = None + ) -> PaginatedResponseOfBroadcastSuppression: + """List broadcast suppressed recipients""" + + params = build_params( + limit=limit, + offset=offset, + ) + data = self._http.get(f"/broadcasts/{id}/suppressions", params=params) return PaginatedResponseOfBroadcastSuppression.model_validate(data) @@ -68,18 +74,44 @@ async def list( limit: int | None = None, offset: int | None = None, ) -> PaginatedResponseOfBroadcast: - params = _list_params(channel_id, status, subject, limit, offset) + """List broadcasts""" + + params = build_params( + channel_id=channel_id, + status=status.value if status else None, + subject=subject, + limit=limit, + offset=offset, + ) data = await self._http.get("/broadcasts", params=params) return PaginatedResponseOfBroadcast.model_validate(data) async def retrieve(self, id: str) -> BroadcastDetailsResponse: + """Retrieve a broadcast""" + data = await self._http.get(f"/broadcasts/{id}") return BroadcastDetailsResponse.model_validate(data) - async def list_failures(self, id: str) -> PaginatedResponseOfBroadcastFailure: - data = await self._http.get(f"/broadcasts/{id}/failures") + async def list_failures( + self, id: str, *, limit: int | None = None, offset: int | None = None + ) -> PaginatedResponseOfBroadcastFailure: + """List failed broadcast messages""" + + params = build_params( + limit=limit, + offset=offset, + ) + data = await self._http.get(f"/broadcasts/{id}/failures", params=params) return PaginatedResponseOfBroadcastFailure.model_validate(data) - async def list_suppressions(self, id: str) -> PaginatedResponseOfBroadcastSuppression: - data = await self._http.get(f"/broadcasts/{id}/suppressions") + async def list_suppressions( + self, id: str, *, limit: int | None = None, offset: int | None = None + ) -> PaginatedResponseOfBroadcastSuppression: + """List broadcast suppressed recipients""" + + params = build_params( + limit=limit, + offset=offset, + ) + data = await self._http.get(f"/broadcasts/{id}/suppressions", params=params) return PaginatedResponseOfBroadcastSuppression.model_validate(data) diff --git a/sdk_helo_email/resources/channels.py b/sdk_helo_email/resources/channels.py index 24477a0..f8b3eee 100644 --- a/sdk_helo_email/resources/channels.py +++ b/sdk_helo_email/resources/channels.py @@ -1,62 +1,15 @@ from __future__ import annotations from collections.abc import Sequence -from typing import Any from .._utils import build_body, build_params -from ..types.channels import ( - ChannelDetailsResponse, - PaginationResultOfChannelBasicResponse, -) -from ..types.params import TrackingParam +from ..types.channels import ChannelDetailsResponse, PaginationResultOfChannelBasicResponse +from ..types.params import CreateChannelTrackingParam, UpdateChannelTrackingParam from ..types.shared import DeliveryType from ._base import AsyncBaseResource, BaseResource -def _create_body( - name: str, delivery_type: DeliveryType, tracking: TrackingParam | None -) -> dict[str, Any]: - return build_body(name=name, delivery_type=delivery_type.value, tracking=tracking) - - -def _update_body( - name: str | None, - delivery_type: DeliveryType | None, - tracking: TrackingParam | None, -) -> dict[str, Any]: - return build_body( - name=name, - delivery_type=delivery_type.value if delivery_type else None, - tracking=tracking, - ) - - -def _list_params( - limit: int | None, - offset: int | None, - name: str | None, - channel_ids: Sequence[str] | None, - delivery_type: DeliveryType | None, -) -> dict[str, Any] | None: - params = build_params(limit=limit, offset=offset, name=name) - if channel_ids is not None: - params["channelIds"] = ",".join(channel_ids) - if delivery_type is not None: - params["deliveryType"] = delivery_type.value - return params or None - - class ChannelsResource(BaseResource): - def create( - self, - *, - name: str, - delivery_type: DeliveryType, - tracking: TrackingParam | None = None, - ) -> ChannelDetailsResponse: - data = self._http.post("/channels", json=_create_body(name, delivery_type, tracking)) - return ChannelDetailsResponse.model_validate(data) - def list( self, *, @@ -66,11 +19,38 @@ def list( channel_ids: Sequence[str] | None = None, delivery_type: DeliveryType | None = None, ) -> PaginationResultOfChannelBasicResponse: - params = _list_params(limit, offset, name, channel_ids, delivery_type) + """List all channels""" + + params = build_params( + limit=limit, + offset=offset, + name=name, + channel_ids=",".join(channel_ids) if channel_ids else None, + delivery_type=delivery_type.value if delivery_type else None, + ) data = self._http.get("/channels", params=params) return PaginationResultOfChannelBasicResponse.model_validate(data) + def create( + self, + *, + name: str, + delivery_type: DeliveryType, + tracking: CreateChannelTrackingParam | None = None, + ) -> ChannelDetailsResponse: + """Create a channel""" + + body = build_body( + name=name, + delivery_type=delivery_type.value, + tracking=tracking, + ) + data = self._http.post("/channels", json=body) + return ChannelDetailsResponse.model_validate(data) + def retrieve(self, id: str) -> ChannelDetailsResponse: + """Retrieve a channel""" + data = self._http.get(f"/channels/{id}") return ChannelDetailsResponse.model_validate(data) @@ -80,26 +60,25 @@ def update( *, name: str | None = None, delivery_type: DeliveryType | None = None, - tracking: TrackingParam | None = None, + tracking: UpdateChannelTrackingParam | None = None, ) -> ChannelDetailsResponse: - data = self._http.patch(f"/channels/{id}", json=_update_body(name, delivery_type, tracking)) + """Update a channel""" + + body = build_body( + name=name, + delivery_type=delivery_type.value if delivery_type else None, + tracking=tracking, + ) + data = self._http.patch(f"/channels/{id}", json=body) return ChannelDetailsResponse.model_validate(data) def delete(self, id: str) -> None: + """Delete a channel""" + self._http.delete(f"/channels/{id}") class AsyncChannelsResource(AsyncBaseResource): - async def create( - self, - *, - name: str, - delivery_type: DeliveryType, - tracking: TrackingParam | None = None, - ) -> ChannelDetailsResponse: - data = await self._http.post("/channels", json=_create_body(name, delivery_type, tracking)) - return ChannelDetailsResponse.model_validate(data) - async def list( self, *, @@ -109,11 +88,38 @@ async def list( channel_ids: Sequence[str] | None = None, delivery_type: DeliveryType | None = None, ) -> PaginationResultOfChannelBasicResponse: - params = _list_params(limit, offset, name, channel_ids, delivery_type) + """List all channels""" + + params = build_params( + limit=limit, + offset=offset, + name=name, + channel_ids=",".join(channel_ids) if channel_ids else None, + delivery_type=delivery_type.value if delivery_type else None, + ) data = await self._http.get("/channels", params=params) return PaginationResultOfChannelBasicResponse.model_validate(data) + async def create( + self, + *, + name: str, + delivery_type: DeliveryType, + tracking: CreateChannelTrackingParam | None = None, + ) -> ChannelDetailsResponse: + """Create a channel""" + + body = build_body( + name=name, + delivery_type=delivery_type.value, + tracking=tracking, + ) + data = await self._http.post("/channels", json=body) + return ChannelDetailsResponse.model_validate(data) + async def retrieve(self, id: str) -> ChannelDetailsResponse: + """Retrieve a channel""" + data = await self._http.get(f"/channels/{id}") return ChannelDetailsResponse.model_validate(data) @@ -123,11 +129,19 @@ async def update( *, name: str | None = None, delivery_type: DeliveryType | None = None, - tracking: TrackingParam | None = None, + tracking: UpdateChannelTrackingParam | None = None, ) -> ChannelDetailsResponse: - body = _update_body(name, delivery_type, tracking) + """Update a channel""" + + body = build_body( + name=name, + delivery_type=delivery_type.value if delivery_type else None, + tracking=tracking, + ) data = await self._http.patch(f"/channels/{id}", json=body) return ChannelDetailsResponse.model_validate(data) async def delete(self, id: str) -> None: + """Delete a channel""" + await self._http.delete(f"/channels/{id}") diff --git a/sdk_helo_email/resources/domains.py b/sdk_helo_email/resources/domains.py index 66fb302..f207648 100644 --- a/sdk_helo_email/resources/domains.py +++ b/sdk_helo_email/resources/domains.py @@ -1,7 +1,6 @@ from __future__ import annotations from collections.abc import Sequence -from typing import Any from .._utils import build_body, build_params from ..types.domains import ( @@ -14,28 +13,7 @@ from ._base import AsyncBaseResource, BaseResource -def _list_params( - limit: int | None, - offset: int | None, - name: str | None, - channel_ids: Sequence[str] | None, -) -> dict[str, Any] | None: - params = build_params(limit=limit, offset=offset, name=name) - if channel_ids is not None: - params["channelIds"] = ",".join(channel_ids) - return params or None - - class DomainsResource(BaseResource): - def create( - self, - *, - name: str, - channel_ids: Sequence[str] | None = None, - ) -> DomainWithDnsResponse: - data = self._http.post("/domains", json=build_body(name=name, channel_ids=channel_ids)) - return DomainWithDnsResponse.model_validate(data) - def list( self, *, @@ -44,45 +22,63 @@ def list( name: str | None = None, channel_ids: Sequence[str] | None = None, ) -> PaginatedResponseOfDomainResponse: - data = self._http.get("/domains", params=_list_params(limit, offset, name, channel_ids)) + """List all domains""" + + params = build_params( + limit=limit, + offset=offset, + name=name, + channel_ids=",".join(channel_ids) if channel_ids else None, + ) + data = self._http.get("/domains", params=params) return PaginatedResponseOfDomainResponse.model_validate(data) + def create( + self, *, name: str, channel_ids: Sequence[str] | None = None + ) -> DomainWithDnsResponse: + """Create a domain""" + + body = build_body( + name=name, + channel_ids=channel_ids if channel_ids else None, + ) + data = self._http.post("/domains", json=body) + return DomainWithDnsResponse.model_validate(data) + def retrieve(self, id: str) -> DomainWithDnsResponse: + """Retrieve a domain""" + data = self._http.get(f"/domains/{id}") return DomainWithDnsResponse.model_validate(data) - def update( - self, - id: str, - *, - channel_ids: Sequence[str] | None = None, - ) -> DomainResponse: - data = self._http.patch(f"/domains/{id}", json=build_body(channel_ids=channel_ids)) + def update(self, id: str, *, channel_ids: Sequence[str] | None = None) -> DomainResponse: + """Update a domain""" + + body = build_body( + channel_ids=channel_ids if channel_ids else None, + ) + data = self._http.patch(f"/domains/{id}", json=body) return DomainResponse.model_validate(data) def delete(self, id: str) -> None: + """Delete a domain""" + self._http.delete(f"/domains/{id}") def verify(self, id: str) -> DnsRecordsResponse: + """Verify a domain""" + data = self._http.post(f"/domains/{id}/verify") return DnsRecordsResponse.model_validate(data) def rotate_key(self, id: str) -> DnsRecordResponse: + """Rotate a domain key""" + data = self._http.post(f"/domains/{id}/rotate-key") return DnsRecordResponse.model_validate(data) class AsyncDomainsResource(AsyncBaseResource): - async def create( - self, - *, - name: str, - channel_ids: Sequence[str] | None = None, - ) -> DomainWithDnsResponse: - body = build_body(name=name, channel_ids=channel_ids) - data = await self._http.post("/domains", json=body) - return DomainWithDnsResponse.model_validate(data) - async def list( self, *, @@ -91,30 +87,57 @@ async def list( name: str | None = None, channel_ids: Sequence[str] | None = None, ) -> PaginatedResponseOfDomainResponse: - params = _list_params(limit, offset, name, channel_ids) + """List all domains""" + + params = build_params( + limit=limit, + offset=offset, + name=name, + channel_ids=",".join(channel_ids) if channel_ids else None, + ) data = await self._http.get("/domains", params=params) return PaginatedResponseOfDomainResponse.model_validate(data) + async def create( + self, *, name: str, channel_ids: Sequence[str] | None = None + ) -> DomainWithDnsResponse: + """Create a domain""" + + body = build_body( + name=name, + channel_ids=channel_ids if channel_ids else None, + ) + data = await self._http.post("/domains", json=body) + return DomainWithDnsResponse.model_validate(data) + async def retrieve(self, id: str) -> DomainWithDnsResponse: + """Retrieve a domain""" + data = await self._http.get(f"/domains/{id}") return DomainWithDnsResponse.model_validate(data) - async def update( - self, - id: str, - *, - channel_ids: Sequence[str] | None = None, - ) -> DomainResponse: - data = await self._http.patch(f"/domains/{id}", json=build_body(channel_ids=channel_ids)) + async def update(self, id: str, *, channel_ids: Sequence[str] | None = None) -> DomainResponse: + """Update a domain""" + + body = build_body( + channel_ids=channel_ids if channel_ids else None, + ) + data = await self._http.patch(f"/domains/{id}", json=body) return DomainResponse.model_validate(data) async def delete(self, id: str) -> None: + """Delete a domain""" + await self._http.delete(f"/domains/{id}") async def verify(self, id: str) -> DnsRecordsResponse: + """Verify a domain""" + data = await self._http.post(f"/domains/{id}/verify") return DnsRecordsResponse.model_validate(data) async def rotate_key(self, id: str) -> DnsRecordResponse: + """Rotate a domain key""" + data = await self._http.post(f"/domains/{id}/rotate-key") return DnsRecordResponse.model_validate(data) diff --git a/sdk_helo_email/resources/sending.py b/sdk_helo_email/resources/sending.py index 548d085..de83f1e 100644 --- a/sdk_helo_email/resources/sending.py +++ b/sdk_helo_email/resources/sending.py @@ -1,16 +1,17 @@ from __future__ import annotations from collections.abc import Sequence -from typing import Any -from .._utils import build_body +from .._utils import build_body, build_headers from ..types.params import ( AttachmentParam, - HeadersParam, MailAddressParam, - MetadataParam, - TemplateParam, - TrackingParam, + SendBroadcastRequestMessageParam, + SendBroadcastRequestTemplateParam, + SendBroadcastRequestTrackingParam, + SendMessageRequestParam, + SendMessageRequestTemplateParam, + SendMessageRequestTrackingParam, ) from ..types.sending import ( SendBroadcastResponse, @@ -20,78 +21,6 @@ from ._base import AsyncBaseResource, BaseResource -def _sending_headers( - channel_id: str | None, - idempotency_key: str | None, -) -> dict[str, str] | None: - headers: dict[str, str] = {} - if channel_id is not None: - headers["X-Helo-Channel-Id"] = channel_id - if idempotency_key is not None: - headers["X-Helo-Idempotency-Key"] = idempotency_key - return headers or None - - -def _message_body( - *, - from_: MailAddressParam, - to: Sequence[MailAddressParam], - cc: Sequence[MailAddressParam] | None, - bcc: Sequence[MailAddressParam] | None, - reply_to: Sequence[MailAddressParam] | None, - subject: str | None, - html: str | None, - text: str | None, - template: TemplateParam | None, - tracking: TrackingParam | None, - attachments: Sequence[AttachmentParam] | None, - tags: Sequence[str] | None, - headers: HeadersParam | None, - metadata: MetadataParam | None, -) -> dict[str, Any]: - return build_body( - from_=from_, - to=to, - cc=cc, - bcc=bcc, - reply_to=reply_to, - subject=subject, - html=html, - text=text, - template=template, - tracking=tracking, - attachments=attachments, - tags=tags, - headers=headers, - metadata=metadata, - ) - - -def _broadcast_body( - *, - from_: MailAddressParam, - messages: Sequence[dict[str, Any]], - template: TemplateParam, - reply_to: Sequence[MailAddressParam] | None, - tracking: TrackingParam | None, - attachments: Sequence[AttachmentParam] | None, - tags: Sequence[str] | None, - headers: HeadersParam | None, - metadata: MetadataParam | None, -) -> dict[str, Any]: - return build_body( - from_=from_, - messages=messages, - template=template, - reply_to=reply_to, - tracking=tracking, - attachments=attachments, - tags=tags, - headers=headers, - metadata=metadata, - ) - - class SendingResource(BaseResource): def transactional( self, @@ -104,66 +33,92 @@ def transactional( subject: str | None = None, html: str | None = None, text: str | None = None, - template: TemplateParam | None = None, - tracking: TrackingParam | None = None, + template: SendMessageRequestTemplateParam | None = None, + tracking: SendMessageRequestTrackingParam | None = None, attachments: Sequence[AttachmentParam] | None = None, tags: Sequence[str] | None = None, - headers: HeadersParam | None = None, - metadata: MetadataParam | None = None, + headers: dict[str, str] | None = None, + metadata: dict[str, str] | None = None, channel_id: str | None = None, idempotency_key: str | None = None, ) -> SendMessageAcceptedResponse: - body = _message_body( - from_=from_, to=to, cc=cc, bcc=bcc, reply_to=reply_to, subject=subject, - html=html, text=text, template=template, tracking=tracking, - attachments=attachments, tags=tags, headers=headers, metadata=metadata, + """Send a transactional email""" + + body = build_body( + from_=from_, + to=[str(item) for item in to] if to else None, + cc=[str(item) for item in cc] if cc else None, + bcc=[str(item) for item in bcc] if bcc else None, + reply_to=[str(item) for item in reply_to] if reply_to else None, + subject=subject, + html=html, + text=text, + template=template, + tracking=tracking, + attachments=[str(item) for item in attachments] if attachments else None, + tags=tags if tags else None, + headers=headers, + metadata=metadata, ) - data = self._http.post( - "/send/transactional", - json=body, - headers=_sending_headers(channel_id, idempotency_key), + headers = build_headers( + **{"X-Helo-Channel-Id": channel_id}, + **{"X-Helo-Idempotency-Key": idempotency_key}, ) + data = self._http.post("/send/transactional", json=body, headers=headers) return SendMessageAcceptedResponse.model_validate(data) def transactional_batch( self, *, - requests: Sequence[dict[str, Any]], + requests: Sequence[SendMessageRequestParam], channel_id: str | None = None, idempotency_key: str | None = None, ) -> SendMessageBatchResponse: - data = self._http.post( - "/send/transactional/batch", - json={"requests": requests}, - headers=_sending_headers(channel_id, idempotency_key), + """Send transactional emails in batch""" + + body = build_body( + requests=[str(item) for item in requests] if requests else None, + ) + headers = build_headers( + **{"X-Helo-Channel-Id": channel_id}, + **{"X-Helo-Idempotency-Key": idempotency_key}, ) + data = self._http.post("/send/transactional/batch", json=body, headers=headers) return SendMessageBatchResponse.model_validate(data) def broadcast( self, *, from_: MailAddressParam, - messages: Sequence[dict[str, Any]], - template: TemplateParam, + template: SendBroadcastRequestTemplateParam, + messages: Sequence[SendBroadcastRequestMessageParam], reply_to: Sequence[MailAddressParam] | None = None, - tracking: TrackingParam | None = None, + tracking: SendBroadcastRequestTrackingParam | None = None, attachments: Sequence[AttachmentParam] | None = None, tags: Sequence[str] | None = None, - headers: HeadersParam | None = None, - metadata: MetadataParam | None = None, + headers: dict[str, str] | None = None, + metadata: dict[str, str] | None = None, channel_id: str | None = None, idempotency_key: str | None = None, ) -> SendBroadcastResponse: - body = _broadcast_body( - from_=from_, messages=messages, template=template, reply_to=reply_to, - tracking=tracking, attachments=attachments, tags=tags, headers=headers, + """Send a broadcast email""" + + body = build_body( + from_=from_, + template=template, + messages=[str(item) for item in messages] if messages else None, + reply_to=[str(item) for item in reply_to] if reply_to else None, + tracking=tracking, + attachments=[str(item) for item in attachments] if attachments else None, + tags=tags if tags else None, + headers=headers, metadata=metadata, ) - data = self._http.post( - "/send/broadcast", - json=body, - headers=_sending_headers(channel_id, idempotency_key), + headers = build_headers( + **{"X-Helo-Channel-Id": channel_id}, + **{"X-Helo-Idempotency-Key": idempotency_key}, ) + data = self._http.post("/send/broadcast", json=body, headers=headers) return SendBroadcastResponse.model_validate(data) def broadcast_message( @@ -177,25 +132,38 @@ def broadcast_message( subject: str | None = None, html: str | None = None, text: str | None = None, - template: TemplateParam | None = None, - tracking: TrackingParam | None = None, + template: SendMessageRequestTemplateParam | None = None, + tracking: SendMessageRequestTrackingParam | None = None, attachments: Sequence[AttachmentParam] | None = None, tags: Sequence[str] | None = None, - headers: HeadersParam | None = None, - metadata: MetadataParam | None = None, + headers: dict[str, str] | None = None, + metadata: dict[str, str] | None = None, channel_id: str | None = None, idempotency_key: str | None = None, ) -> SendMessageAcceptedResponse: - body = _message_body( - from_=from_, to=to, cc=cc, bcc=bcc, reply_to=reply_to, subject=subject, - html=html, text=text, template=template, tracking=tracking, - attachments=attachments, tags=tags, headers=headers, metadata=metadata, + """Send a single broadcast email""" + + body = build_body( + from_=from_, + to=[str(item) for item in to] if to else None, + cc=[str(item) for item in cc] if cc else None, + bcc=[str(item) for item in bcc] if bcc else None, + reply_to=[str(item) for item in reply_to] if reply_to else None, + subject=subject, + html=html, + text=text, + template=template, + tracking=tracking, + attachments=[str(item) for item in attachments] if attachments else None, + tags=tags if tags else None, + headers=headers, + metadata=metadata, ) - data = self._http.post( - "/send/broadcast/message", - json=body, - headers=_sending_headers(channel_id, idempotency_key), + headers = build_headers( + **{"X-Helo-Channel-Id": channel_id}, + **{"X-Helo-Idempotency-Key": idempotency_key}, ) + data = self._http.post("/send/broadcast/message", json=body, headers=headers) return SendMessageAcceptedResponse.model_validate(data) @@ -211,66 +179,92 @@ async def transactional( subject: str | None = None, html: str | None = None, text: str | None = None, - template: TemplateParam | None = None, - tracking: TrackingParam | None = None, + template: SendMessageRequestTemplateParam | None = None, + tracking: SendMessageRequestTrackingParam | None = None, attachments: Sequence[AttachmentParam] | None = None, tags: Sequence[str] | None = None, - headers: HeadersParam | None = None, - metadata: MetadataParam | None = None, + headers: dict[str, str] | None = None, + metadata: dict[str, str] | None = None, channel_id: str | None = None, idempotency_key: str | None = None, ) -> SendMessageAcceptedResponse: - body = _message_body( - from_=from_, to=to, cc=cc, bcc=bcc, reply_to=reply_to, subject=subject, - html=html, text=text, template=template, tracking=tracking, - attachments=attachments, tags=tags, headers=headers, metadata=metadata, + """Send a transactional email""" + + body = build_body( + from_=from_, + to=[str(item) for item in to] if to else None, + cc=[str(item) for item in cc] if cc else None, + bcc=[str(item) for item in bcc] if bcc else None, + reply_to=[str(item) for item in reply_to] if reply_to else None, + subject=subject, + html=html, + text=text, + template=template, + tracking=tracking, + attachments=[str(item) for item in attachments] if attachments else None, + tags=tags if tags else None, + headers=headers, + metadata=metadata, ) - data = await self._http.post( - "/send/transactional", - json=body, - headers=_sending_headers(channel_id, idempotency_key), + headers = build_headers( + **{"X-Helo-Channel-Id": channel_id}, + **{"X-Helo-Idempotency-Key": idempotency_key}, ) + data = await self._http.post("/send/transactional", json=body, headers=headers) return SendMessageAcceptedResponse.model_validate(data) async def transactional_batch( self, *, - requests: Sequence[dict[str, Any]], + requests: Sequence[SendMessageRequestParam], channel_id: str | None = None, idempotency_key: str | None = None, ) -> SendMessageBatchResponse: - data = await self._http.post( - "/send/transactional/batch", - json={"requests": requests}, - headers=_sending_headers(channel_id, idempotency_key), + """Send transactional emails in batch""" + + body = build_body( + requests=[str(item) for item in requests] if requests else None, + ) + headers = build_headers( + **{"X-Helo-Channel-Id": channel_id}, + **{"X-Helo-Idempotency-Key": idempotency_key}, ) + data = await self._http.post("/send/transactional/batch", json=body, headers=headers) return SendMessageBatchResponse.model_validate(data) async def broadcast( self, *, from_: MailAddressParam, - messages: Sequence[dict[str, Any]], - template: TemplateParam, + template: SendBroadcastRequestTemplateParam, + messages: Sequence[SendBroadcastRequestMessageParam], reply_to: Sequence[MailAddressParam] | None = None, - tracking: TrackingParam | None = None, + tracking: SendBroadcastRequestTrackingParam | None = None, attachments: Sequence[AttachmentParam] | None = None, tags: Sequence[str] | None = None, - headers: HeadersParam | None = None, - metadata: MetadataParam | None = None, + headers: dict[str, str] | None = None, + metadata: dict[str, str] | None = None, channel_id: str | None = None, idempotency_key: str | None = None, ) -> SendBroadcastResponse: - body = _broadcast_body( - from_=from_, messages=messages, template=template, reply_to=reply_to, - tracking=tracking, attachments=attachments, tags=tags, headers=headers, + """Send a broadcast email""" + + body = build_body( + from_=from_, + template=template, + messages=[str(item) for item in messages] if messages else None, + reply_to=[str(item) for item in reply_to] if reply_to else None, + tracking=tracking, + attachments=[str(item) for item in attachments] if attachments else None, + tags=tags if tags else None, + headers=headers, metadata=metadata, ) - data = await self._http.post( - "/send/broadcast", - json=body, - headers=_sending_headers(channel_id, idempotency_key), + headers = build_headers( + **{"X-Helo-Channel-Id": channel_id}, + **{"X-Helo-Idempotency-Key": idempotency_key}, ) + data = await self._http.post("/send/broadcast", json=body, headers=headers) return SendBroadcastResponse.model_validate(data) async def broadcast_message( @@ -284,23 +278,36 @@ async def broadcast_message( subject: str | None = None, html: str | None = None, text: str | None = None, - template: TemplateParam | None = None, - tracking: TrackingParam | None = None, + template: SendMessageRequestTemplateParam | None = None, + tracking: SendMessageRequestTrackingParam | None = None, attachments: Sequence[AttachmentParam] | None = None, tags: Sequence[str] | None = None, - headers: HeadersParam | None = None, - metadata: MetadataParam | None = None, + headers: dict[str, str] | None = None, + metadata: dict[str, str] | None = None, channel_id: str | None = None, idempotency_key: str | None = None, ) -> SendMessageAcceptedResponse: - body = _message_body( - from_=from_, to=to, cc=cc, bcc=bcc, reply_to=reply_to, subject=subject, - html=html, text=text, template=template, tracking=tracking, - attachments=attachments, tags=tags, headers=headers, metadata=metadata, + """Send a single broadcast email""" + + body = build_body( + from_=from_, + to=[str(item) for item in to] if to else None, + cc=[str(item) for item in cc] if cc else None, + bcc=[str(item) for item in bcc] if bcc else None, + reply_to=[str(item) for item in reply_to] if reply_to else None, + subject=subject, + html=html, + text=text, + template=template, + tracking=tracking, + attachments=[str(item) for item in attachments] if attachments else None, + tags=tags if tags else None, + headers=headers, + metadata=metadata, ) - data = await self._http.post( - "/send/broadcast/message", - json=body, - headers=_sending_headers(channel_id, idempotency_key), + headers = build_headers( + **{"X-Helo-Channel-Id": channel_id}, + **{"X-Helo-Idempotency-Key": idempotency_key}, ) + data = await self._http.post("/send/broadcast/message", json=body, headers=headers) return SendMessageAcceptedResponse.model_validate(data) diff --git a/sdk_helo_email/resources/statistics.py b/sdk_helo_email/resources/statistics.py index 43cb393..e7501ef 100644 --- a/sdk_helo_email/resources/statistics.py +++ b/sdk_helo_email/resources/statistics.py @@ -1,8 +1,8 @@ from __future__ import annotations from collections.abc import Sequence -from typing import Any +from .._utils import build_params from ..types.statistics import ( StatisticsDailyResponse, StatisticsHourlyResponse, @@ -11,23 +11,6 @@ from ._base import AsyncBaseResource, BaseResource -def _range_params( - from_: str, - to: str, - channel_id: str | None, - tags: Sequence[str] | None, - timezone: str | None = None, -) -> dict[str, Any]: - params: dict[str, Any] = {"from": from_, "to": to} - if timezone is not None: - params["timezone"] = timezone - if channel_id is not None: - params["channelId"] = channel_id - if tags is not None: - params["tags"] = tags - return params - - class StatisticsResource(BaseResource): def retrieve_hourly( self, @@ -37,7 +20,14 @@ def retrieve_hourly( channel_id: str | None = None, tags: Sequence[str] | None = None, ) -> StatisticsHourlyResponse: - params = _range_params(from_, to, channel_id, tags) + """Retrieve hourly statistics""" + + params = build_params( + from_=from_, + to=to, + channel_id=channel_id, + tags=",".join(tags) if tags else None, + ) data = self._http.get("/statistics/hourly", params=params) return StatisticsHourlyResponse.model_validate(data) @@ -50,7 +40,15 @@ def retrieve_daily( channel_id: str | None = None, tags: Sequence[str] | None = None, ) -> StatisticsDailyResponse: - params = _range_params(from_, to, channel_id, tags, timezone=timezone) + """Retrieve daily statistics""" + + params = build_params( + from_=from_, + to=to, + timezone=timezone, + channel_id=channel_id, + tags=",".join(tags) if tags else None, + ) data = self._http.get("/statistics/daily", params=params) return StatisticsDailyResponse.model_validate(data) @@ -62,7 +60,14 @@ def retrieve_totals( channel_id: str | None = None, tags: Sequence[str] | None = None, ) -> StatisticsTotalsResponse: - params = _range_params(from_, to, channel_id, tags) + """Retrieve all time statistics""" + + params = build_params( + from_=from_, + to=to, + channel_id=channel_id, + tags=",".join(tags) if tags else None, + ) data = self._http.get("/statistics/totals", params=params) return StatisticsTotalsResponse.model_validate(data) @@ -76,7 +81,14 @@ async def retrieve_hourly( channel_id: str | None = None, tags: Sequence[str] | None = None, ) -> StatisticsHourlyResponse: - params = _range_params(from_, to, channel_id, tags) + """Retrieve hourly statistics""" + + params = build_params( + from_=from_, + to=to, + channel_id=channel_id, + tags=",".join(tags) if tags else None, + ) data = await self._http.get("/statistics/hourly", params=params) return StatisticsHourlyResponse.model_validate(data) @@ -89,7 +101,15 @@ async def retrieve_daily( channel_id: str | None = None, tags: Sequence[str] | None = None, ) -> StatisticsDailyResponse: - params = _range_params(from_, to, channel_id, tags, timezone=timezone) + """Retrieve daily statistics""" + + params = build_params( + from_=from_, + to=to, + timezone=timezone, + channel_id=channel_id, + tags=",".join(tags) if tags else None, + ) data = await self._http.get("/statistics/daily", params=params) return StatisticsDailyResponse.model_validate(data) @@ -101,6 +121,13 @@ async def retrieve_totals( channel_id: str | None = None, tags: Sequence[str] | None = None, ) -> StatisticsTotalsResponse: - params = _range_params(from_, to, channel_id, tags) + """Retrieve all time statistics""" + + params = build_params( + from_=from_, + to=to, + channel_id=channel_id, + tags=",".join(tags) if tags else None, + ) data = await self._http.get("/statistics/totals", params=params) return StatisticsTotalsResponse.model_validate(data) diff --git a/sdk_helo_email/resources/suppressions.py b/sdk_helo_email/resources/suppressions.py index eae457c..17f83c7 100644 --- a/sdk_helo_email/resources/suppressions.py +++ b/sdk_helo_email/resources/suppressions.py @@ -1,8 +1,8 @@ from __future__ import annotations from collections.abc import Sequence -from typing import Any +from .._utils import build_body, build_params from ..types.shared import MailType, SuppressionReason from ..types.suppressions import ( CreateSuppressionsResponse, @@ -12,30 +12,6 @@ from ._base import AsyncBaseResource, BaseResource -def _list_params( - channel_id: str, - mail_type: MailType, - reason: SuppressionReason | None, - email: str | None, - limit: int | None, - offset: int | None, -) -> dict[str, Any]: - params: dict[str, Any] = {"channelId": channel_id, "mailType": mail_type.value} - if reason is not None: - params["reason"] = reason.value - if email is not None: - params["email"] = email - if limit is not None: - params["limit"] = limit - if offset is not None: - params["offset"] = offset - return params - - -def _mutation_body(channel_id: str, mail_type: MailType, emails: Sequence[str]) -> dict[str, Any]: - return {"channelId": channel_id, "mailType": mail_type.value, "emails": emails} - - class SuppressionsResource(BaseResource): def list( self, @@ -47,29 +23,42 @@ def list( limit: int | None = None, offset: int | None = None, ) -> PaginatedResponseOfSuppressionResponse: - params = _list_params(channel_id, mail_type, reason, email, limit, offset) + """List suppressions""" + + params = build_params( + channel_id=channel_id, + mail_type=mail_type.value, + reason=reason.value if reason else None, + email=email, + limit=limit, + offset=offset, + ) data = self._http.get("/suppressions", params=params) return PaginatedResponseOfSuppressionResponse.model_validate(data) def create( - self, - *, - channel_id: str, - mail_type: MailType, - emails: Sequence[str], + self, *, channel_id: str, mail_type: MailType, emails: Sequence[str] ) -> CreateSuppressionsResponse: - body = _mutation_body(channel_id, mail_type, emails) + """Create suppressions""" + + body = build_body( + channel_id=channel_id, + mail_type=mail_type.value, + emails=emails if emails else None, + ) data = self._http.post("/suppressions", json=body) return CreateSuppressionsResponse.model_validate(data) def remove( - self, - *, - channel_id: str, - mail_type: MailType, - emails: Sequence[str], + self, *, channel_id: str, mail_type: MailType, emails: Sequence[str] ) -> RemoveSuppressionsResponse: - body = _mutation_body(channel_id, mail_type, emails) + """Remove suppressions""" + + body = build_body( + channel_id=channel_id, + mail_type=mail_type.value, + emails=emails if emails else None, + ) data = self._http.post("/suppressions/remove", json=body) return RemoveSuppressionsResponse.model_validate(data) @@ -85,28 +74,41 @@ async def list( limit: int | None = None, offset: int | None = None, ) -> PaginatedResponseOfSuppressionResponse: - params = _list_params(channel_id, mail_type, reason, email, limit, offset) + """List suppressions""" + + params = build_params( + channel_id=channel_id, + mail_type=mail_type.value, + reason=reason.value if reason else None, + email=email, + limit=limit, + offset=offset, + ) data = await self._http.get("/suppressions", params=params) return PaginatedResponseOfSuppressionResponse.model_validate(data) async def create( - self, - *, - channel_id: str, - mail_type: MailType, - emails: Sequence[str], + self, *, channel_id: str, mail_type: MailType, emails: Sequence[str] ) -> CreateSuppressionsResponse: - body = _mutation_body(channel_id, mail_type, emails) + """Create suppressions""" + + body = build_body( + channel_id=channel_id, + mail_type=mail_type.value, + emails=emails if emails else None, + ) data = await self._http.post("/suppressions", json=body) return CreateSuppressionsResponse.model_validate(data) async def remove( - self, - *, - channel_id: str, - mail_type: MailType, - emails: Sequence[str], + self, *, channel_id: str, mail_type: MailType, emails: Sequence[str] ) -> RemoveSuppressionsResponse: - body = _mutation_body(channel_id, mail_type, emails) + """Remove suppressions""" + + body = build_body( + channel_id=channel_id, + mail_type=mail_type.value, + emails=emails if emails else None, + ) data = await self._http.post("/suppressions/remove", json=body) return RemoveSuppressionsResponse.model_validate(data) diff --git a/sdk_helo_email/resources/webhook_endpoints.py b/sdk_helo_email/resources/webhook_endpoints.py deleted file mode 100644 index b06e3cd..0000000 --- a/sdk_helo_email/resources/webhook_endpoints.py +++ /dev/null @@ -1,166 +0,0 @@ -from __future__ import annotations - -from collections.abc import Sequence -from typing import Any - -from ..types.params import WebhookHeaderParam -from ..types.shared import WebhookEvent -from ..types.webhook_endpoints import ( - PaginationResultOfWebhookEndpointResponse, - WebhookEndpointResponse, -) -from ._base import AsyncBaseResource, BaseResource - - -def _create_body( - url: str, - events: Sequence[WebhookEvent], - channel_id: str | None, - additional_headers: Sequence[WebhookHeaderParam] | None, - enabled: bool | None, -) -> dict[str, Any]: - body: dict[str, Any] = {"url": url, "events": [e.value for e in events]} - if channel_id is not None: - body["channelId"] = channel_id - if additional_headers is not None: - body["additionalHeaders"] = additional_headers - if enabled is not None: - body["enabled"] = enabled - return body - - -def _update_body( - url: str | None, - events: Sequence[WebhookEvent] | None, - channel_id: str | None, - additional_headers: Sequence[WebhookHeaderParam] | None, - enabled: bool | None, -) -> dict[str, Any]: - body: dict[str, Any] = {} - if url is not None: - body["url"] = url - if events is not None: - body["events"] = [e.value for e in events] - if channel_id is not None: - body["channelId"] = channel_id - if additional_headers is not None: - body["additionalHeaders"] = additional_headers - if enabled is not None: - body["enabled"] = enabled - return body - - -def _list_params( - limit: int | None, - offset: int | None, - channel_ids: Sequence[str] | None, -) -> dict[str, Any] | None: - params: dict[str, Any] = {} - if limit is not None: - params["limit"] = limit - if offset is not None: - params["offset"] = offset - if channel_ids is not None: - params["channelIds"] = ",".join(channel_ids) - return params or None - - -class WebhookEndpointsResource(BaseResource): - def create( - self, - *, - url: str, - events: Sequence[WebhookEvent], - channel_id: str | None = None, - additional_headers: Sequence[WebhookHeaderParam] | None = None, - enabled: bool | None = None, - ) -> WebhookEndpointResponse: - body = _create_body(url, events, channel_id, additional_headers, enabled) - data = self._http.post("/webhook-endpoints", json=body) - return WebhookEndpointResponse.model_validate(data) - - def list( - self, - *, - limit: int | None = None, - offset: int | None = None, - channel_ids: Sequence[str] | None = None, - ) -> PaginationResultOfWebhookEndpointResponse: - data = self._http.get("/webhook-endpoints", params=_list_params(limit, offset, channel_ids)) - return PaginationResultOfWebhookEndpointResponse.model_validate(data) - - def retrieve(self, id: str) -> WebhookEndpointResponse: - data = self._http.get(f"/webhook-endpoints/{id}") - return WebhookEndpointResponse.model_validate(data) - - def update( - self, - id: str, - *, - url: str | None = None, - events: Sequence[WebhookEvent] | None = None, - channel_id: str | None = None, - additional_headers: Sequence[WebhookHeaderParam] | None = None, - enabled: bool | None = None, - ) -> WebhookEndpointResponse: - body = _update_body(url, events, channel_id, additional_headers, enabled) - data = self._http.patch(f"/webhook-endpoints/{id}", json=body) - return WebhookEndpointResponse.model_validate(data) - - def delete(self, id: str) -> None: - self._http.delete(f"/webhook-endpoints/{id}") - - def regenerate_signing_key(self, id: str) -> WebhookEndpointResponse: - data = self._http.post(f"/webhook-endpoints/{id}/regenerate-signing-key") - return WebhookEndpointResponse.model_validate(data) - - -class AsyncWebhookEndpointsResource(AsyncBaseResource): - async def create( - self, - *, - url: str, - events: Sequence[WebhookEvent], - channel_id: str | None = None, - additional_headers: Sequence[WebhookHeaderParam] | None = None, - enabled: bool | None = None, - ) -> WebhookEndpointResponse: - body = _create_body(url, events, channel_id, additional_headers, enabled) - data = await self._http.post("/webhook-endpoints", json=body) - return WebhookEndpointResponse.model_validate(data) - - async def list( - self, - *, - limit: int | None = None, - offset: int | None = None, - channel_ids: Sequence[str] | None = None, - ) -> PaginationResultOfWebhookEndpointResponse: - params = _list_params(limit, offset, channel_ids) - data = await self._http.get("/webhook-endpoints", params=params) - return PaginationResultOfWebhookEndpointResponse.model_validate(data) - - async def retrieve(self, id: str) -> WebhookEndpointResponse: - data = await self._http.get(f"/webhook-endpoints/{id}") - return WebhookEndpointResponse.model_validate(data) - - async def update( - self, - id: str, - *, - url: str | None = None, - events: Sequence[WebhookEvent] | None = None, - channel_id: str | None = None, - additional_headers: Sequence[WebhookHeaderParam] | None = None, - enabled: bool | None = None, - ) -> WebhookEndpointResponse: - body = _update_body(url, events, channel_id, additional_headers, enabled) - data = await self._http.patch(f"/webhook-endpoints/{id}", json=body) - return WebhookEndpointResponse.model_validate(data) - - async def delete(self, id: str) -> None: - await self._http.delete(f"/webhook-endpoints/{id}") - - async def regenerate_signing_key(self, id: str) -> WebhookEndpointResponse: - data = await self._http.post(f"/webhook-endpoints/{id}/regenerate-signing-key") - return WebhookEndpointResponse.model_validate(data) diff --git a/sdk_helo_email/resources/webhooks.py b/sdk_helo_email/resources/webhooks.py new file mode 100644 index 0000000..6658698 --- /dev/null +++ b/sdk_helo_email/resources/webhooks.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from collections.abc import Sequence + +from .._utils import build_body, build_params +from ..types.params import WebhookHeaderParam +from ..types.shared import WebhookEvent +from ..types.webhooks import PaginationResultOfWebhookResponse, WebhookResponse +from ._base import AsyncBaseResource, BaseResource + + +class WebhooksResource(BaseResource): + def list( + self, + *, + limit: int | None = None, + offset: int | None = None, + channel_ids: Sequence[str] | None = None, + ) -> PaginationResultOfWebhookResponse: + """List all webhooks""" + + params = build_params( + limit=limit, + offset=offset, + channel_ids=",".join(channel_ids) if channel_ids else None, + ) + data = self._http.get("/webhooks", params=params) + return PaginationResultOfWebhookResponse.model_validate(data) + + def create( + self, + *, + url: str, + events: Sequence[WebhookEvent], + channel_id: str | None = None, + additional_headers: Sequence[WebhookHeaderParam] | None = None, + enabled: bool | None = None, + ) -> WebhookResponse: + """Create a webhook""" + + body = build_body( + url=url, + events=[item.value for item in events] if events else None, + channel_id=channel_id, + additional_headers=[str(item) for item in additional_headers] + if additional_headers + else None, + enabled=enabled, + ) + data = self._http.post("/webhooks", json=body) + return WebhookResponse.model_validate(data) + + def retrieve(self, id: str) -> WebhookResponse: + """Retrieve a webhook""" + + data = self._http.get(f"/webhooks/{id}") + return WebhookResponse.model_validate(data) + + def update( + self, + id: str, + *, + url: str | None = None, + events: Sequence[WebhookEvent] | None = None, + channel_id: str | None = None, + additional_headers: Sequence[WebhookHeaderParam] | None = None, + enabled: bool | None = None, + ) -> WebhookResponse: + """Update a webhook""" + + body = build_body( + url=url, + events=[item.value for item in events] if events else None, + channel_id=channel_id, + additional_headers=[str(item) for item in additional_headers] + if additional_headers + else None, + enabled=enabled, + ) + data = self._http.patch(f"/webhooks/{id}", json=body) + return WebhookResponse.model_validate(data) + + def delete(self, id: str) -> None: + """Delete a webhook""" + + self._http.delete(f"/webhooks/{id}") + + def regenerate_signing_key(self, id: str) -> WebhookResponse: + """Regenerate webhook signing key""" + + data = self._http.post(f"/webhooks/{id}/regenerate-signing-key") + return WebhookResponse.model_validate(data) + + +class AsyncWebhooksResource(AsyncBaseResource): + async def list( + self, + *, + limit: int | None = None, + offset: int | None = None, + channel_ids: Sequence[str] | None = None, + ) -> PaginationResultOfWebhookResponse: + """List all webhooks""" + + params = build_params( + limit=limit, + offset=offset, + channel_ids=",".join(channel_ids) if channel_ids else None, + ) + data = await self._http.get("/webhooks", params=params) + return PaginationResultOfWebhookResponse.model_validate(data) + + async def create( + self, + *, + url: str, + events: Sequence[WebhookEvent], + channel_id: str | None = None, + additional_headers: Sequence[WebhookHeaderParam] | None = None, + enabled: bool | None = None, + ) -> WebhookResponse: + """Create a webhook""" + + body = build_body( + url=url, + events=[item.value for item in events] if events else None, + channel_id=channel_id, + additional_headers=[str(item) for item in additional_headers] + if additional_headers + else None, + enabled=enabled, + ) + data = await self._http.post("/webhooks", json=body) + return WebhookResponse.model_validate(data) + + async def retrieve(self, id: str) -> WebhookResponse: + """Retrieve a webhook""" + + data = await self._http.get(f"/webhooks/{id}") + return WebhookResponse.model_validate(data) + + async def update( + self, + id: str, + *, + url: str | None = None, + events: Sequence[WebhookEvent] | None = None, + channel_id: str | None = None, + additional_headers: Sequence[WebhookHeaderParam] | None = None, + enabled: bool | None = None, + ) -> WebhookResponse: + """Update a webhook""" + + body = build_body( + url=url, + events=[item.value for item in events] if events else None, + channel_id=channel_id, + additional_headers=[str(item) for item in additional_headers] + if additional_headers + else None, + enabled=enabled, + ) + data = await self._http.patch(f"/webhooks/{id}", json=body) + return WebhookResponse.model_validate(data) + + async def delete(self, id: str) -> None: + """Delete a webhook""" + + await self._http.delete(f"/webhooks/{id}") + + async def regenerate_signing_key(self, id: str) -> WebhookResponse: + """Regenerate webhook signing key""" + + data = await self._http.post(f"/webhooks/{id}/regenerate-signing-key") + return WebhookResponse.model_validate(data) diff --git a/sdk_helo_email/types/__init__.py b/sdk_helo_email/types/__init__.py index 1b3f45f..6733ef8 100644 --- a/sdk_helo_email/types/__init__.py +++ b/sdk_helo_email/types/__init__.py @@ -1,14 +1,19 @@ from .activity import ( ActivityEvent, + ActivityMailAddress, Message, MessageDetailsResponse, - MessageEvent, - MessageTracking, + MessageDetailsResponseAttachment, + MessageDetailsResponseEvent, + MessageDetailsResponseTracking, + MessageStatistics, PaginatedEventsResponse, PaginatedMessagesResponse, ) from .broadcasts import ( BroadcastContent, + BroadcastContentAttachment, + BroadcastContentTemplate, BroadcastDetailsResponse, BroadcastFailureResponse, BroadcastResponse, @@ -23,21 +28,37 @@ ChannelBasicResponse, ChannelDetailsResponse, ChannelTracking, + CreateChannelRequest, + CreateChannelTracking, PaginationResultOfChannelBasicResponse, + UpdateChannelRequest, + UpdateChannelTracking, ) from .domains import ( + CreateDomainRequest, DnsRecordResponse, DnsRecordsResponse, DomainChannelResponse, DomainResponse, DomainWithDnsResponse, PaginatedResponseOfDomainResponse, + UpdateDomainRequest, ) from .sending import ( + Attachment, + SendBroadcastRequest, + SendBroadcastRequestMessage, + SendBroadcastRequestTemplate, + SendBroadcastRequestTracking, SendBroadcastResponse, SendMessageAcceptedResponse, + SendMessageBatchRequest, SendMessageBatchResponse, SendMessageFailedResponse, + SendMessageRequest, + SendMessageRequestTemplate, + SendMessageRequestTracking, + SendMessageResponse, ) from .shared import ( AttachmentDisposition, @@ -46,88 +67,169 @@ DnsRecordStatus, DnsRecordType, EventType, + HeloModel, MailAddress, + MailSource, MailType, + MessageStatus, SuppressionReason, WebhookEvent, ) from .statistics import ( - DailyStatisticsEntry, DeliveryStats, - HourlyStatisticsEntry, StatisticsDailyResponse, + StatisticsDailyResponseResult, StatisticsHourlyResponse, + StatisticsHourlyResponseResult, StatisticsTotalsResponse, ) from .suppressions import ( + CreateSuppressionsRequest, CreateSuppressionsResponse, PaginatedResponseOfSuppressionResponse, RemoveSuppressionResult, + RemoveSuppressionsRequest, RemoveSuppressionsResponse, SuppressionResponse, SuppressionResult, ) -from .webhook_endpoints import ( - PaginationResultOfWebhookEndpointResponse, - WebhookEndpointResponse, +from .webhooks import ( + AcceptedWebhookPayload, + BouncedDetails, + BouncedWebhookPayload, + ClickedDetails, + ClickedWebhookPayload, + ClientDetails, + ComplainedDetails, + ComplainedWebhookPayload, + CreateWebhookRequest, + DeliveredDetails, + DeliveredWebhookPayload, + DeviceDetails, + DomainKeyVerificationFailedPayload, + DomainKeyVerifiedPayload, + OpenedDetails, + OpenedWebhookPayload, + PaginationResultOfWebhookResponse, + ProcessedWebhookPayload, + ResubscribedDetails, + ResubscribedWebhookPayload, + ReturnPathDomainVerificationFailedPayload, + ReturnPathDomainVerifiedPayload, + UnsubscribedDetails, + UnsubscribedWebhookPayload, + UpdateWebhookRequest, WebhookHeader, + WebhookLastResponse, + WebhookResponse, ) __all__ = [ + "AcceptedWebhookPayload", + "ActivityEvent", + "ActivityMailAddress", + "Attachment", "AttachmentDisposition", + "BouncedDetails", + "BouncedWebhookPayload", + "BroadcastContent", + "BroadcastContentAttachment", + "BroadcastContentTemplate", + "BroadcastDetailsResponse", + "BroadcastFailureResponse", + "BroadcastResponse", + "BroadcastStatistics", "BroadcastStatus", + "BroadcastTracking", + "ChannelBasicResponse", + "ChannelDetailsResponse", + "ChannelTracking", + "ClickedDetails", + "ClickedWebhookPayload", + "ClientDetails", + "ComplainedDetails", + "ComplainedWebhookPayload", + "CreateChannelRequest", + "CreateChannelTracking", + "CreateDomainRequest", + "CreateSuppressionsRequest", + "CreateSuppressionsResponse", + "CreateWebhookRequest", + "DeliveredDetails", + "DeliveredWebhookPayload", + "DeliveryStats", "DeliveryType", + "DeviceDetails", + "DnsRecordResponse", "DnsRecordStatus", "DnsRecordType", + "DnsRecordsResponse", + "DomainChannelResponse", + "DomainKeyVerificationFailedPayload", + "DomainKeyVerifiedPayload", + "DomainResponse", + "DomainWithDnsResponse", "EventType", + "HeloModel", "MailAddress", + "MailSource", "MailType", - "SuppressionReason", - "WebhookEvent", - "ChannelBasicResponse", - "ChannelDetailsResponse", - "ChannelTracking", - "PaginationResultOfChannelBasicResponse", - "ActivityEvent", "Message", "MessageDetailsResponse", - "MessageEvent", - "MessageTracking", + "MessageDetailsResponseAttachment", + "MessageDetailsResponseEvent", + "MessageDetailsResponseTracking", + "MessageStatistics", + "MessageStatus", + "OpenedDetails", + "OpenedWebhookPayload", "PaginatedEventsResponse", "PaginatedMessagesResponse", - "DnsRecordResponse", - "DnsRecordsResponse", - "DomainChannelResponse", - "DomainResponse", - "DomainWithDnsResponse", + "PaginatedResponseOfBroadcast", + "PaginatedResponseOfBroadcastFailure", + "PaginatedResponseOfBroadcastSuppression", "PaginatedResponseOfDomainResponse", + "PaginatedResponseOfSuppressionResponse", + "PaginationResultOfChannelBasicResponse", + "PaginationResultOfWebhookResponse", + "ProcessedWebhookPayload", + "RecipientHeaders", + "RemoveSuppressionResult", + "RemoveSuppressionsRequest", + "RemoveSuppressionsResponse", + "ResubscribedDetails", + "ResubscribedWebhookPayload", + "ReturnPathDomainVerificationFailedPayload", + "ReturnPathDomainVerifiedPayload", + "SendBroadcastRequest", + "SendBroadcastRequestMessage", + "SendBroadcastRequestTemplate", + "SendBroadcastRequestTracking", "SendBroadcastResponse", "SendMessageAcceptedResponse", + "SendMessageBatchRequest", "SendMessageBatchResponse", "SendMessageFailedResponse", - "BroadcastContent", - "BroadcastDetailsResponse", - "BroadcastFailureResponse", - "BroadcastResponse", - "BroadcastStatistics", - "BroadcastTracking", - "PaginatedResponseOfBroadcast", - "PaginatedResponseOfBroadcastFailure", - "PaginatedResponseOfBroadcastSuppression", - "RecipientHeaders", - "DeliveryStats", - "DailyStatisticsEntry", - "HourlyStatisticsEntry", + "SendMessageRequest", + "SendMessageRequestTemplate", + "SendMessageRequestTracking", + "SendMessageResponse", "StatisticsDailyResponse", + "StatisticsDailyResponseResult", "StatisticsHourlyResponse", + "StatisticsHourlyResponseResult", "StatisticsTotalsResponse", - "CreateSuppressionsResponse", - "PaginatedResponseOfSuppressionResponse", - "RemoveSuppressionsResponse", - "RemoveSuppressionResult", + "SuppressionReason", "SuppressionResponse", "SuppressionResult", - "PaginationResultOfWebhookEndpointResponse", - "WebhookEndpointResponse", + "UnsubscribedDetails", + "UnsubscribedWebhookPayload", + "UpdateChannelRequest", + "UpdateChannelTracking", + "UpdateDomainRequest", + "UpdateWebhookRequest", + "WebhookEvent", "WebhookHeader", + "WebhookLastResponse", + "WebhookResponse", ] diff --git a/sdk_helo_email/types/activity.py b/sdk_helo_email/types/activity.py index 3097ac5..5f8d3a1 100644 --- a/sdk_helo_email/types/activity.py +++ b/sdk_helo_email/types/activity.py @@ -3,20 +3,35 @@ from datetime import datetime from typing import Any -from .shared import EventType, HeloModel, MailAddress, MailType +from pydantic import Field + +from .shared import ( + AttachmentDisposition, + DeliveryType, + EventType, + HeloModel, + MailSource, + MailType, + MessageStatus, +) + + +class ActivityMailAddress(HeloModel): + email: str + name: str | None = None class ActivityEvent(HeloModel): message_id: str channel_id: str mail_type: MailType - mail_source: str | None = None + mail_source: MailSource | None = None event_type: EventType timestamp: datetime subject: str recipients: list[str] tags: list[str] | None = None - metadata: dict[str, Any] | None = None + metadata: dict[str, str] | None = None details: dict[str, Any] | None = None @@ -26,16 +41,27 @@ class PaginatedEventsResponse(HeloModel): results: list[ActivityEvent] +class MessageStatistics(HeloModel): + delivered: int + bounced: int + opened: int + clicked: int + complained: int + unsubscribed: int + + class Message(HeloModel): message_id: str channel_id: str timestamp: datetime mail_type: MailType - mail_source: str - delivery_type: str - status: str + mail_source: MailSource + delivery_type: DeliveryType + status: MessageStatus subject: str recipients: list[str] + tags: list[str] | None = None + statistics: MessageStatistics class PaginatedMessagesResponse(HeloModel): @@ -44,45 +70,45 @@ class PaginatedMessagesResponse(HeloModel): results: list[Message] -class MessageEvent(HeloModel): - event_type: EventType - timestamp: datetime - recipient: str | None = None - details: dict[str, Any] | None = None +class MessageDetailsResponseAttachment(HeloModel): + file_name: str + disposition: AttachmentDisposition + size: float -class MessageTracking(HeloModel): +class MessageDetailsResponseTracking(HeloModel): links: bool opens: bool +class MessageDetailsResponseEvent(HeloModel): + event_type: EventType + timestamp: datetime + recipients: list[str] + details: dict[str, Any] | None = None + + class MessageDetailsResponse(HeloModel): message_id: str channel_id: str timestamp: datetime mail_type: MailType - mail_source: str - delivery_type: str - status: str + mail_source: MailSource + delivery_type: DeliveryType + status: MessageStatus subject: str - from_: MailAddress - to: list[MailAddress] - cc: list[MailAddress] | None = None - bcc: list[MailAddress] | None = None - reply_to: list[MailAddress] | None = None + from_: ActivityMailAddress = Field(alias="from") + to: list[ActivityMailAddress] + cc: list[ActivityMailAddress] | None = None + bcc: list[ActivityMailAddress] | None = None + reply_to: list[ActivityMailAddress] | None = None text: str | None = None html: str | None = None body: str | None = None tags: list[str] | None = None - headers: dict[str, Any] | None = None - metadata: dict[str, Any] | None = None - attachments: list[str] | None = None - tracking: MessageTracking - events: list[MessageEvent] - - @classmethod - def model_validate(cls, obj: Any, **kwargs: Any) -> MessageDetailsResponse: - if isinstance(obj, dict) and "from" in obj and "from_" not in obj: - obj = dict(obj) - obj["from_"] = obj.pop("from") - return super().model_validate(obj, **kwargs) + headers: dict[str, str] | None = None + metadata: dict[str, str] | None = None + attachments: list[MessageDetailsResponseAttachment] | None = None + tracking: MessageDetailsResponseTracking + events: list[MessageDetailsResponseEvent] + statistics: MessageStatistics | None = None diff --git a/sdk_helo_email/types/broadcasts.py b/sdk_helo_email/types/broadcasts.py index 8a9b3b2..31f4925 100644 --- a/sdk_helo_email/types/broadcasts.py +++ b/sdk_helo_email/types/broadcasts.py @@ -1,62 +1,56 @@ from __future__ import annotations from datetime import datetime -from typing import Any -from .shared import BroadcastStatus, HeloModel, MailAddress +from pydantic import Field +from .shared import AttachmentDisposition, BroadcastStatus, HeloModel, MailAddress -class BroadcastTracking(HeloModel): - opens: bool - links: bool - -class BroadcastStatistics(HeloModel): - sent: int - delivered: int - bounced: int - opened: int - clicked: int - complained: int - unsubscribed: int +class BroadcastResponse(HeloModel): + id: str + created_at: datetime + status: BroadcastStatus + subject: str + completion: str + messages: int -class BroadcastTemplateContent(HeloModel): +class BroadcastContentTemplate(HeloModel): subject: str html: str | None = None text: str | None = None -class BroadcastAttachment(HeloModel): +class BroadcastContentAttachment(HeloModel): file_name: str - disposition: str + disposition: AttachmentDisposition size: int class BroadcastContent(HeloModel): - from_: MailAddress | None = None + from_: MailAddress | None = Field(default=None, alias="from") reply_to: list[MailAddress] | None = None - template: BroadcastTemplateContent | None = None - attachments: list[BroadcastAttachment] | None = None + template: BroadcastContentTemplate | None = None + attachments: list[BroadcastContentAttachment] | None = None tags: list[str] | None = None - headers: dict[str, Any] | None = None - metadata: dict[str, Any] | None = None + headers: dict[str, str] | None = None + metadata: dict[str, str] | None = None - @classmethod - def model_validate(cls, obj: Any, **kwargs: Any) -> BroadcastContent: - if isinstance(obj, dict) and "from" in obj and "from_" not in obj: - obj = dict(obj) - obj["from_"] = obj.pop("from") - return super().model_validate(obj, **kwargs) +class BroadcastTracking(HeloModel): + opens: bool + links: bool -class BroadcastResponse(HeloModel): - id: str - created_at: datetime - status: BroadcastStatus - subject: str - completion: str - messages: int + +class BroadcastStatistics(HeloModel): + sent: int + delivered: int + bounced: int + opened: int + clicked: int + complained: int + unsubscribed: int class BroadcastDetailsResponse(HeloModel): @@ -73,11 +67,6 @@ class BroadcastDetailsResponse(HeloModel): statistics: BroadcastStatistics -class PaginatedResponseOfBroadcast(HeloModel): - total_count: int - results: list[BroadcastResponse] - - class RecipientHeaders(HeloModel): to: list[MailAddress] cc: list[MailAddress] | None = None @@ -91,6 +80,11 @@ class BroadcastFailureResponse(HeloModel): error_message: str +class PaginatedResponseOfBroadcast(HeloModel): + total_count: int + results: list[BroadcastResponse] + + class PaginatedResponseOfBroadcastFailure(HeloModel): total_count: int results: list[BroadcastFailureResponse] diff --git a/sdk_helo_email/types/channels.py b/sdk_helo_email/types/channels.py index 55fd5da..6a72f4b 100644 --- a/sdk_helo_email/types/channels.py +++ b/sdk_helo_email/types/channels.py @@ -11,21 +11,43 @@ class ChannelTracking(HeloModel): class ChannelDetailsResponse(HeloModel): - id: str - name: str - delivery_type: DeliveryType - created_at: datetime - updated_at: datetime - tracking: ChannelTracking + id: str | None = None + name: str | None = None + delivery_type: DeliveryType | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + tracking: ChannelTracking | None = None class ChannelBasicResponse(HeloModel): - id: str + id: str | None = None + name: str | None = None + delivery_type: DeliveryType | None = None + created_at: datetime | None = None + + +class CreateChannelTracking(HeloModel): + links: bool | None = None + opens: bool | None = None + + +class CreateChannelRequest(HeloModel): name: str delivery_type: DeliveryType - created_at: datetime + tracking: CreateChannelTracking | None = None class PaginationResultOfChannelBasicResponse(HeloModel): results: list[ChannelBasicResponse] total_count: int + + +class UpdateChannelTracking(HeloModel): + links: bool | None = None + opens: bool | None = None + + +class UpdateChannelRequest(HeloModel): + name: str | None = None + delivery_type: DeliveryType | None = None + tracking: UpdateChannelTracking | None = None diff --git a/sdk_helo_email/types/domains.py b/sdk_helo_email/types/domains.py index a639719..756d469 100644 --- a/sdk_helo_email/types/domains.py +++ b/sdk_helo_email/types/domains.py @@ -2,21 +2,22 @@ from datetime import datetime +from pydantic import Field + from .shared import DeliveryType, DnsRecordStatus, DnsRecordType, HeloModel -class DomainChannelResponse(HeloModel): - id: str +class CreateDomainRequest(HeloModel): name: str - delivery_type: DeliveryType - deleted: bool | None = None + channel_ids: list[str] | None = None class DnsRecordResponse(HeloModel): - type: DnsRecordType | None = None + type_: DnsRecordType | None = Field(default=None, alias="type") host: str | None = None value: str | None = None status: DnsRecordStatus | None = None + last_checked_at: datetime | None = None class DnsRecordsResponse(HeloModel): @@ -25,6 +26,13 @@ class DnsRecordsResponse(HeloModel): return_path: list[DnsRecordResponse] | None = None +class DomainChannelResponse(HeloModel): + id: str + name: str + delivery_type: DeliveryType + deleted: bool | None = None + + class DomainResponse(HeloModel): id: str created_at: datetime @@ -45,3 +53,7 @@ class DomainWithDnsResponse(HeloModel): class PaginatedResponseOfDomainResponse(HeloModel): total_count: int results: list[DomainResponse] + + +class UpdateDomainRequest(HeloModel): + channel_ids: list[str] | None = None diff --git a/sdk_helo_email/types/params.py b/sdk_helo_email/types/params.py index e8ddb54..60796da 100644 --- a/sdk_helo_email/types/params.py +++ b/sdk_helo_email/types/params.py @@ -1,45 +1,106 @@ -"""TypedDicts for request parameters. +"""TypedDicts for the object-shaped values accepted by request bodies. -These give editor completion and static checking for the dict-shaped arguments -accepted by the resource methods. They are plain ``dict`` subclasses at runtime, -so existing callers passing literal dicts keep working. +They give editor completion and static checking for arguments that would +otherwise be bare dicts, and are plain ``dict`` subclasses at runtime, so passing +a literal dict keeps working. -Note: nested objects are sent to the API verbatim, so their keys use the API's -casing (e.g. ``fileName``), unlike the snake_case keyword arguments on resource -methods which are converted automatically. +Keys use the API's own casing (``fileName``), because nested objects are +forwarded to the API verbatim — unlike the snake_case keyword arguments on +resource methods, which are converted for you. """ from __future__ import annotations +from collections.abc import Sequence from typing import Any from typing_extensions import NotRequired, TypedDict +from .shared import AttachmentDisposition + + +class CreateChannelTrackingParam(TypedDict, total=False): + links: bool + opens: bool + + +class UpdateChannelTrackingParam(TypedDict, total=False): + links: bool + opens: bool + class MailAddressParam(TypedDict): email: str name: NotRequired[str] -class TrackingParam(TypedDict, total=False): - links: bool +class SendMessageRequestTemplateParam(TypedDict, total=False): + subject: str + html: str + text: str + inlineStyles: bool + data: dict[str, Any] + + +class SendMessageRequestTrackingParam(TypedDict, total=False): opens: bool + links: bool class AttachmentParam(TypedDict): content: str - fileName: str - disposition: str - contentType: NotRequired[str] contentId: NotRequired[str] + contentType: NotRequired[str] + fileName: str + disposition: AttachmentDisposition + + +# `SendMessageRequest` has a key that is a Python keyword, so it needs the +# functional TypedDict form. +SendMessageRequestParam = TypedDict( + "SendMessageRequestParam", + { + "from": MailAddressParam, + "to": Sequence[MailAddressParam], + "cc": NotRequired[Sequence[MailAddressParam]], + "bcc": NotRequired[Sequence[MailAddressParam]], + "replyTo": NotRequired[Sequence[MailAddressParam]], + "subject": NotRequired[str], + "html": NotRequired[str], + "text": NotRequired[str], + "template": NotRequired[SendMessageRequestTemplateParam], + "tracking": NotRequired[SendMessageRequestTrackingParam], + "attachments": NotRequired[Sequence[AttachmentParam]], + "tags": NotRequired[Sequence[str]], + "headers": NotRequired[dict[str, str]], + "metadata": NotRequired[dict[str, str]], + }, +) + + +class SendBroadcastRequestTemplateParam(TypedDict): + subject: str + html: NotRequired[str] + text: NotRequired[str] + inlineStyles: NotRequired[bool] + data: NotRequired[dict[str, Any]] + + +class SendBroadcastRequestTrackingParam(TypedDict, total=False): + opens: bool + links: bool + + +class SendBroadcastRequestMessageParam(TypedDict): + to: Sequence[MailAddressParam] + cc: NotRequired[Sequence[MailAddressParam]] + bcc: NotRequired[Sequence[MailAddressParam]] + tags: NotRequired[Sequence[str]] + headers: NotRequired[dict[str, str]] + metadata: NotRequired[dict[str, str]] + data: NotRequired[dict[str, Any]] class WebhookHeaderParam(TypedDict): name: str value: str - - -# Free-form objects forwarded to the API as-is. -TemplateParam = dict[str, Any] -MetadataParam = dict[str, Any] -HeadersParam = dict[str, str] diff --git a/sdk_helo_email/types/sending.py b/sdk_helo_email/types/sending.py index 6f78d05..d3c6b0d 100644 --- a/sdk_helo_email/types/sending.py +++ b/sdk_helo_email/types/sending.py @@ -1,6 +1,102 @@ from __future__ import annotations -from .shared import HeloModel +from typing import Any + +from pydantic import Field + +from .shared import AttachmentDisposition, HeloModel, MailAddress + + +class Attachment(HeloModel): + content: str + content_id: str | None = None + content_type: str | None = None + file_name: str + disposition: AttachmentDisposition + + +class SendBroadcastRequestTemplate(HeloModel): + """Email template applied to every message in the broadcast. At least one of `html` or `text` + is required. + """ + + subject: str + html: str | None = None + text: str | None = None + inline_styles: bool | None = None + data: dict[str, Any] | None = None + + +class SendBroadcastRequestTracking(HeloModel): + """Override channel-level open and link tracking settings.""" + + opens: bool | None = None + links: bool | None = None + + +class SendBroadcastRequestMessage(HeloModel): + to: list[MailAddress] + cc: list[MailAddress] | None = None + bcc: list[MailAddress] | None = None + tags: list[str] | None = None + headers: dict[str, str] | None = None + metadata: dict[str, str] | None = None + data: dict[str, Any] | None = None + + +class SendBroadcastRequest(HeloModel): + from_: MailAddress = Field(alias="from") + reply_to: list[MailAddress] | None = None + template: SendBroadcastRequestTemplate + tracking: SendBroadcastRequestTracking | None = None + attachments: list[Attachment] | None = None + tags: list[str] | None = None + headers: dict[str, str] | None = None + metadata: dict[str, str] | None = None + messages: list[SendBroadcastRequestMessage] + + +class SendBroadcastResponse(HeloModel): + status: str | None = None + broadcast_id: str | None = None + + +class SendMessageRequestTemplate(HeloModel): + subject: str | None = None + html: str | None = None + text: str | None = None + inline_styles: bool | None = None + data: dict[str, Any] | None = None + + +class SendMessageRequestTracking(HeloModel): + opens: bool | None = None + links: bool | None = None + + +class SendMessageRequest(HeloModel): + from_: MailAddress = Field(alias="from") + to: list[MailAddress] + cc: list[MailAddress] | None = None + bcc: list[MailAddress] | None = None + reply_to: list[MailAddress] | None = None + subject: str | None = None + html: str | None = None + text: str | None = None + template: SendMessageRequestTemplate | None = None + tracking: SendMessageRequestTracking | None = None + attachments: list[Attachment] | None = None + tags: list[str] | None = None + headers: dict[str, str] | None = None + metadata: dict[str, str] | None = None + + +class SendMessageResponse(HeloModel): + status: str + message_id: str | None = None + suppressions: list[str] | None = None + error_code: str | None = None + error_message: str | None = None class SendMessageAcceptedResponse(HeloModel): @@ -15,10 +111,9 @@ class SendMessageFailedResponse(HeloModel): error_message: str -class SendMessageBatchResponse(HeloModel): - responses: list[SendMessageAcceptedResponse] +class SendMessageBatchRequest(HeloModel): + requests: list[SendMessageRequest] -class SendBroadcastResponse(HeloModel): - status: str | None = None - broadcast_id: str | None = None +class SendMessageBatchResponse(HeloModel): + responses: list[SendMessageResponse] diff --git a/sdk_helo_email/types/shared.py b/sdk_helo_email/types/shared.py index ff2f965..317cb36 100644 --- a/sdk_helo_email/types/shared.py +++ b/sdk_helo_email/types/shared.py @@ -7,20 +7,54 @@ class HeloModel(BaseModel): + """Base for every response model: parses the API's camelCase wire names.""" + model_config = ConfigDict( alias_generator=to_camel, populate_by_name=True, ) +class AttachmentDisposition(str, Enum): + """How the attachment is presented. Use `attachment` for downloadable files and `inline` for + embedded content (e.g. images referenced in HTML). + """ + + ATTACHMENT = "attachment" + INLINE = "inline" + + +class BroadcastStatus(str, Enum): + """Current processing state of a broadcast. `accepted` — queued, waiting for the channel to + become available. `processing` — actively sending messages. `completed` — all messages + have been processed. `canceled` — stopped before completing. + """ + + ACCEPTED = "accepted" + PROCESSING = "processing" + COMPLETED = "completed" + CANCELED = "canceled" + + class DeliveryType(str, Enum): + """Delivery mode for a channel. `live` sends real emails, whereas `sandbox` accepts and + processes messages without delivering them to recipients. + """ + LIVE = "live" SANDBOX = "sandbox" -class MailType(str, Enum): - TRANSACTIONAL = "transactional" - BROADCAST = "broadcast" +class DnsRecordStatus(str, Enum): + PENDING = "pending" + VERIFIED = "verified" + FAILING = "failing" + FAILED = "failed" + + +class DnsRecordType(str, Enum): + TXT = "txt" + CNAME = "cname" class EventType(str, Enum): @@ -32,23 +66,22 @@ class EventType(str, Enum): CLICKED = "clicked" COMPLAINED = "complained" UNSUBSCRIBED = "unsubscribed" + RESUBSCRIBED = "resubscribed" -class AttachmentDisposition(str, Enum): - ATTACHMENT = "attachment" - INLINE = "inline" +class MailSource(str, Enum): + API = "api" + SMTP = "smtp" -class DnsRecordStatus(str, Enum): - PENDING = "pending" - VERIFIED = "verified" - FAILING = "failing" - FAILED = "failed" +class MailType(str, Enum): + TRANSACTIONAL = "transactional" + BROADCAST = "broadcast" -class DnsRecordType(str, Enum): - TXT = "txt" - CNAME = "cname" +class MessageStatus(str, Enum): + QUEUED = "queued" + SENT = "sent" class SuppressionReason(str, Enum): @@ -58,23 +91,22 @@ class SuppressionReason(str, Enum): MANUAL = "manual" -class BroadcastStatus(str, Enum): - ACCEPTED = "accepted" - PROCESSING = "processing" - COMPLETED = "completed" - CANCELLED = "cancelled" - - class WebhookEvent(str, Enum): + """Email lifecycle event type that can trigger a webhook delivery.""" + ACCEPTED = "accepted" PROCESSED = "processed" - BOUNCED = "bounced" DELIVERED = "delivered" + BOUNCED = "bounced" OPENED = "opened" CLICKED = "clicked" COMPLAINED = "complained" UNSUBSCRIBED = "unsubscribed" RESUBSCRIBED = "resubscribed" + DOMAIN_KEY_VERIFIED = "domain-key-verified" + DOMAIN_KEY_VERIFICATION_FAILED = "domain-key-verification-failed" + RETURN_PATH_DOMAIN_VERIFIED = "return-path-domain-verified" + RETURN_PATH_DOMAIN_VERIFICATION_FAILED = "return-path-domain-verification-failed" class MailAddress(HeloModel): diff --git a/sdk_helo_email/types/statistics.py b/sdk_helo_email/types/statistics.py index 7b53045..4e0326a 100644 --- a/sdk_helo_email/types/statistics.py +++ b/sdk_helo_email/types/statistics.py @@ -20,21 +20,21 @@ class StatisticsTotalsResponse(HeloModel): broadcast: DeliveryStats | None = None -class DailyStatisticsEntry(HeloModel): - timestamp: date +class StatisticsDailyResponseResult(HeloModel): + timestamp: date | None = None transactional: DeliveryStats | None = None broadcast: DeliveryStats | None = None class StatisticsDailyResponse(HeloModel): - results: list[DailyStatisticsEntry] + results: list[StatisticsDailyResponseResult] | None = None -class HourlyStatisticsEntry(HeloModel): - timestamp: datetime +class StatisticsHourlyResponseResult(HeloModel): + timestamp: datetime | None = None transactional: DeliveryStats | None = None broadcast: DeliveryStats | None = None class StatisticsHourlyResponse(HeloModel): - results: list[HourlyStatisticsEntry] + results: list[StatisticsHourlyResponseResult] | None = None diff --git a/sdk_helo_email/types/suppressions.py b/sdk_helo_email/types/suppressions.py index 744957d..9163b6d 100644 --- a/sdk_helo_email/types/suppressions.py +++ b/sdk_helo_email/types/suppressions.py @@ -2,7 +2,7 @@ from datetime import datetime -from .shared import HeloModel, SuppressionReason +from .shared import HeloModel, MailType, SuppressionReason class SuppressionResponse(HeloModel): @@ -16,6 +16,12 @@ class PaginatedResponseOfSuppressionResponse(HeloModel): results: list[SuppressionResponse] +class CreateSuppressionsRequest(HeloModel): + channel_id: str + mail_type: MailType + emails: list[str] + + class SuppressionResult(HeloModel): email: str success: bool @@ -26,6 +32,12 @@ class CreateSuppressionsResponse(HeloModel): results: list[SuppressionResult] +class RemoveSuppressionsRequest(HeloModel): + channel_id: str + mail_type: MailType + emails: list[str] + + class RemoveSuppressionResult(HeloModel): email: str success: bool diff --git a/sdk_helo_email/types/webhook_endpoints.py b/sdk_helo_email/types/webhook_endpoints.py deleted file mode 100644 index df4adae..0000000 --- a/sdk_helo_email/types/webhook_endpoints.py +++ /dev/null @@ -1,23 +0,0 @@ -from __future__ import annotations - -from .shared import HeloModel, WebhookEvent - - -class WebhookHeader(HeloModel): - name: str - value: str - - -class WebhookEndpointResponse(HeloModel): - id: str | None = None - channel_id: str | None = None - url: str | None = None - payload_signing_key: str | None = None - enabled: bool | None = None - additional_headers: list[WebhookHeader] | None = None - events: list[WebhookEvent] | None = None - - -class PaginationResultOfWebhookEndpointResponse(HeloModel): - results: list[WebhookEndpointResponse] - total_count: int diff --git a/sdk_helo_email/types/webhooks.py b/sdk_helo_email/types/webhooks.py new file mode 100644 index 0000000..2c8db73 --- /dev/null +++ b/sdk_helo_email/types/webhooks.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import Field + +from .shared import EventType, HeloModel, MailType, WebhookEvent + + +class WebhookHeader(HeloModel): + """A custom HTTP header to include in webhook deliveries.""" + + name: str + value: str + + +class CreateWebhookRequest(HeloModel): + """Request body for creating a new webhook.""" + + url: str + events: list[WebhookEvent] + channel_id: str | None = None + additional_headers: list[WebhookHeader] | None = None + enabled: bool | None = None + + +class WebhookLastResponse(HeloModel): + """The most recent delivery outcome recorded for a webhook.""" + + status_code: int | None = None + error: str | None = None + at: datetime | None = None + + +class WebhookResponse(HeloModel): + """Webhook configuration properties.""" + + id: str | None = None + channel_id: str | None = None + url: str | None = None + payload_signing_key: str | None = None + enabled: bool | None = None + additional_headers: list[WebhookHeader] | None = None + events: list[WebhookEvent] | None = None + last_response: WebhookLastResponse | None = None + + +class PaginationResultOfWebhookResponse(HeloModel): + """Paginated list of webhooks.""" + + results: list[WebhookResponse] + total_count: int + + +class UpdateWebhookRequest(HeloModel): + """Request body for updating a webhook. Only provided fields are changed.""" + + url: str | None = None + events: list[WebhookEvent] | None = None + channel_id: str | None = None + additional_headers: list[WebhookHeader] | None = None + enabled: bool | None = None + + +class AcceptedWebhookPayload(HeloModel): + """Payload delivered for the `accepted` event.""" + + event_type: EventType + recipients: list[str] + message_id: str + channel_id: str + mail_type: MailType + subject: str | None = None + tags: list[str] | None = None + metadata: dict[str, str] | None = None + timestamp: datetime + + +class ProcessedWebhookPayload(HeloModel): + """Payload delivered for the `processed` event.""" + + event_type: EventType + recipients: list[str] + message_id: str + channel_id: str + mail_type: MailType + subject: str | None = None + tags: list[str] | None = None + metadata: dict[str, str] | None = None + timestamp: datetime + + +class DeliveredDetails(HeloModel): + """Details of a `delivered` event.""" + + response: str | None = None + + +class DeliveredWebhookPayload(HeloModel): + """Payload delivered for the `delivered` event.""" + + event_type: EventType + details: DeliveredDetails | None = None + recipient: str + message_id: str + channel_id: str + mail_type: MailType + subject: str | None = None + tags: list[str] | None = None + metadata: dict[str, str] | None = None + timestamp: datetime + + +class BouncedDetails(HeloModel): + """Details of a `bounced` event.""" + + type_: str | None = Field(default=None, alias="type") + sub_type: str | None = None + code: str | None = None + + +class BouncedWebhookPayload(HeloModel): + """Payload delivered for the `bounced` event.""" + + event_type: EventType + details: BouncedDetails | None = None + recipient: str + message_id: str + channel_id: str + mail_type: MailType + subject: str | None = None + tags: list[str] | None = None + metadata: dict[str, str] | None = None + timestamp: datetime + + +class ClientDetails(HeloModel): + """The mail client the recipient engaged with the message from.""" + + family: str | None = None + version: str | None = None + + +class DeviceDetails(HeloModel): + """The device the recipient engaged with the message from.""" + + brand: str | None = None + family: str | None = None + model: str | None = None + + +class OpenedDetails(HeloModel): + """Details of an `opened` event.""" + + ip: str | None = None + country: str | None = None + country_code: str | None = None + client: ClientDetails | None = None + device: DeviceDetails | None = None + + +class OpenedWebhookPayload(HeloModel): + """Payload delivered for the `opened` event.""" + + event_type: EventType + details: OpenedDetails | None = None + recipient: str + message_id: str + channel_id: str + mail_type: MailType + subject: str | None = None + tags: list[str] | None = None + metadata: dict[str, str] | None = None + timestamp: datetime + + +class ClickedDetails(HeloModel): + """Details of a `clicked` event.""" + + link: str | None = None + ip: str | None = None + country: str | None = None + country_code: str | None = None + client: ClientDetails | None = None + device: DeviceDetails | None = None + + +class ClickedWebhookPayload(HeloModel): + """Payload delivered for the `clicked` event.""" + + event_type: EventType + details: ClickedDetails | None = None + recipient: str + message_id: str + channel_id: str + mail_type: MailType + subject: str | None = None + tags: list[str] | None = None + metadata: dict[str, str] | None = None + timestamp: datetime + + +class ComplainedDetails(HeloModel): + """Details of a `complained` event.""" + + type_: str | None = Field(default=None, alias="type") + + +class ComplainedWebhookPayload(HeloModel): + """Payload delivered for the `complained` event.""" + + event_type: EventType + details: ComplainedDetails | None = None + recipient: str + message_id: str + channel_id: str + mail_type: MailType + subject: str | None = None + tags: list[str] | None = None + metadata: dict[str, str] | None = None + timestamp: datetime + + +class UnsubscribedDetails(HeloModel): + """Details of an `unsubscribed` event.""" + + ip: str | None = None + + +class UnsubscribedWebhookPayload(HeloModel): + """Payload delivered for the `unsubscribed` event.""" + + event_type: EventType + details: UnsubscribedDetails | None = None + recipient: str + message_id: str + channel_id: str + mail_type: MailType + subject: str | None = None + tags: list[str] | None = None + metadata: dict[str, str] | None = None + timestamp: datetime + + +class ResubscribedDetails(HeloModel): + """Details of a `resubscribed` event.""" + + ip: str | None = None + + +class ResubscribedWebhookPayload(HeloModel): + """Payload delivered for the `resubscribed` event.""" + + event_type: EventType + details: ResubscribedDetails | None = None + recipient: str + message_id: str + channel_id: str + mail_type: MailType + subject: str | None = None + tags: list[str] | None = None + metadata: dict[str, str] | None = None + timestamp: datetime + + +class DomainKeyVerifiedPayload(HeloModel): + """Payload delivered when a domain key is verified.""" + + event_type: WebhookEvent + domain_id: str + domain_name: str + dns_record_host: str + timestamp: datetime + + +class DomainKeyVerificationFailedPayload(HeloModel): + """Payload delivered when a domain key verification fails.""" + + event_type: WebhookEvent + domain_id: str + domain_name: str + dns_record_host: str + timestamp: datetime + + +class ReturnPathDomainVerifiedPayload(HeloModel): + """Payload delivered when a return path domain is verified.""" + + event_type: WebhookEvent + domain_id: str + domain_name: str + dns_record_host: str + timestamp: datetime + + +class ReturnPathDomainVerificationFailedPayload(HeloModel): + """Payload delivered when a return path domain verification fails.""" + + event_type: WebhookEvent + domain_id: str + domain_name: str + dns_record_host: str + timestamp: datetime diff --git a/tests/conftest.py b/tests/conftest.py index 4f5e2f7..8d7d471 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,44 +1,19 @@ from __future__ import annotations -import os - import pytest +from pytest_httpx import HTTPXMock -import sdk_helo_email - - -def pytest_addoption(parser: pytest.Parser) -> None: - parser.addoption( - "--integration", - action="store_true", - default=False, - help="run integration tests against the live Helo API", - ) - - -def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: - """Skip ``@pytest.mark.integration`` tests unless ``--integration`` is passed.""" - if config.getoption("--integration"): - return - skip = pytest.mark.skip(reason="live API test; pass --integration to run") - for item in items: - if "integration" in item.keywords: - item.add_marker(skip) +import sdk_helo_email as helo +BASE_URL = "https://api.example.test" -LIVE_BASE_URL = os.environ.get("HELO_BASE_URL", "http://localhost:8000") +@pytest.fixture +def client(httpx_mock: HTTPXMock) -> helo.Helo: + """A client whose HTTP calls are intercepted, so no server is needed.""" + return helo.Helo(api_key="test-key", base_url=BASE_URL) -@pytest.fixture(scope="session") -def live_client() -> sdk_helo_email.Helo: - """A client pointed at a live API, configured from the environment. - Defaults to a local instance at ``http://localhost:8000``; override with - ``HELO_BASE_URL``. Skips the test if ``HELO_API_KEY`` is not set so the suite - stays runnable without credentials. - """ - api_key = os.environ.get("HELO_API_KEY") - if not api_key: - pytest.skip("HELO_API_KEY not set") - with sdk_helo_email.Helo(api_key=api_key, base_url=LIVE_BASE_URL) as client: - yield client +@pytest.fixture +def async_client(httpx_mock: HTTPXMock) -> helo.AsyncHelo: + return helo.AsyncHelo(api_key="test-key", base_url=BASE_URL) diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/helpers/domains.py b/tests/helpers/domains.py deleted file mode 100644 index a3d09c8..0000000 --- a/tests/helpers/domains.py +++ /dev/null @@ -1,32 +0,0 @@ -import httpx - -class InternalHttpClient: - def __init__(self, base_url: str = "http://localhost:8002") -> None: - self._client = httpx.Client( - base_url=base_url - ) - - def verify_domain(self, domain_id: str, account_id: str = "e6cc89f4-432d-466f-8032-3b2a3484a4b2") -> None: - credential_json = { - "clientId": "dc1b9ac1-dd00-44a9-88c0-47e7d193c709", - "clientSecret": "019634d1-bc75-7000-a2b5-5f002e0361de" - } - credential_response = self._client.request("POST", "/internal/client-credentials/token", json=credential_json, headers={ - "Content-Type": "application/json" - }) - if credential_response.status_code != 200: - raise Exception(f"Received status code {credential_response.status_code} from credential endpoint") - - cred_resp_json = credential_response.json() - token = cred_resp_json["accessToken"] - domain_resp = self._client.request( - "POST", - "/internal/test-helpers/domains/verify", - json={"id": domain_id, "accountId": account_id}, - headers={ - "Content-Type": "application/json", - "Authorization": f"Bearer {token}", - }, - ) - if domain_resp.status_code != 204: - raise Exception(f"Received status code {domain_resp.status_code} from domain verification endpoint") \ No newline at end of file diff --git a/tests/test_activity.py b/tests/test_activity.py new file mode 100644 index 0000000..58c63f1 --- /dev/null +++ b/tests/test_activity.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from pytest_httpx import HTTPXMock + +import sdk_helo_email as helo + + +def test_list_events(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="GET", + json={ + "totalCount": 1.0, + "results": [ + { + "messageId": "550e8400-e29b-41d4-a716-446655440000", + "channelId": "550e8400-e29b-41d4-a716-446655440000", + "mailType": "transactional", + "eventType": "accepted", + "timestamp": "2024-01-01T00:00:00Z", + "subject": "example", + "recipients": ["example"], + } + ], + }, + ) + + client.activity.list_events() + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "GET" + assert request.url.path == "/activity/events" + + +def test_list_messages(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="GET", + json={ + "totalCount": 1.0, + "results": [ + { + "messageId": "550e8400-e29b-41d4-a716-446655440000", + "channelId": "550e8400-e29b-41d4-a716-446655440000", + "timestamp": "2024-01-01T00:00:00Z", + "mailType": "transactional", + "mailSource": "api", + "deliveryType": "live", + "status": "queued", + "subject": "example", + "recipients": ["example"], + "statistics": { + "delivered": 1, + "bounced": 1, + "opened": 1, + "clicked": 1, + "complained": 1, + "unsubscribed": 1, + }, + } + ], + }, + ) + + client.activity.list_messages() + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "GET" + assert request.url.path == "/activity/messages" + + +def test_retrieve_message(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="GET", + json={ + "messageId": "550e8400-e29b-41d4-a716-446655440000", + "channelId": "550e8400-e29b-41d4-a716-446655440000", + "timestamp": "2024-01-01T00:00:00Z", + "mailType": "transactional", + "mailSource": "api", + "deliveryType": "live", + "status": "queued", + "subject": "example", + "from": {"email": "test@example.com"}, + "to": [{"email": "test@example.com"}], + "tracking": {"links": True, "opens": True}, + "events": [ + { + "eventType": "accepted", + "timestamp": "2024-01-01T00:00:00Z", + "recipients": ["example"], + } + ], + }, + ) + + client.activity.retrieve_message( + "550e8400-e29b-41d4-a716-446655440000", + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "GET" + assert request.url.path == "/activity/messages/550e8400-e29b-41d4-a716-446655440000" + + +async def test_list_events_async(async_client: helo.AsyncHelo, httpx_mock: HTTPXMock) -> None: + """The async resource is generated from the same operation, so one call proves the pair.""" + httpx_mock.add_response( + method="GET", + json={ + "totalCount": 1.0, + "results": [ + { + "messageId": "550e8400-e29b-41d4-a716-446655440000", + "channelId": "550e8400-e29b-41d4-a716-446655440000", + "mailType": "transactional", + "eventType": "accepted", + "timestamp": "2024-01-01T00:00:00Z", + "subject": "example", + "recipients": ["example"], + } + ], + }, + ) + + await async_client.activity.list_events() + + request = httpx_mock.get_request() + assert request is not None + assert request.url.path == "/activity/events" diff --git a/tests/test_broadcasts.py b/tests/test_broadcasts.py new file mode 100644 index 0000000..acbf9e6 --- /dev/null +++ b/tests/test_broadcasts.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from pytest_httpx import HTTPXMock + +import sdk_helo_email as helo + + +def test_list(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="GET", + json={ + "totalCount": 1, + "results": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "createdAt": "2024-01-01T00:00:00Z", + "status": "accepted", + "subject": "example", + "completion": "example", + "messages": 1, + } + ], + }, + ) + + client.broadcasts.list( + channel_id="550e8400-e29b-41d4-a716-446655440000", + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "GET" + assert request.url.path == "/broadcasts" + + +def test_retrieve(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="GET", + json={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "createdAt": "2024-01-01T00:00:00Z", + "status": "accepted", + "subject": "example", + "completion": "example", + "messages": 1, + "failed": 1, + "suppressed": 1, + "content": {}, + "tracking": {"opens": True, "links": True}, + "statistics": { + "sent": 1, + "delivered": 1, + "bounced": 1, + "opened": 1, + "clicked": 1, + "complained": 1, + "unsubscribed": 1, + }, + }, + ) + + client.broadcasts.retrieve( + "550e8400-e29b-41d4-a716-446655440000", + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "GET" + assert request.url.path == "/broadcasts/550e8400-e29b-41d4-a716-446655440000" + + +def test_list_failures(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="GET", + json={ + "totalCount": 1, + "results": [ + { + "recipients": {"to": [{"email": "example"}]}, + "messageIndex": 1, + "errorCode": "example", + "errorMessage": "example", + } + ], + }, + ) + + client.broadcasts.list_failures( + "550e8400-e29b-41d4-a716-446655440000", + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "GET" + assert request.url.path == "/broadcasts/550e8400-e29b-41d4-a716-446655440000/failures" + + +def test_list_suppressions(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="GET", + json={"totalCount": 1, "results": ["example"]}, + ) + + client.broadcasts.list_suppressions( + "550e8400-e29b-41d4-a716-446655440000", + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "GET" + assert request.url.path == "/broadcasts/550e8400-e29b-41d4-a716-446655440000/suppressions" + + +async def test_list_async(async_client: helo.AsyncHelo, httpx_mock: HTTPXMock) -> None: + """The async resource is generated from the same operation, so one call proves the pair.""" + httpx_mock.add_response( + method="GET", + json={ + "totalCount": 1, + "results": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "createdAt": "2024-01-01T00:00:00Z", + "status": "accepted", + "subject": "example", + "completion": "example", + "messages": 1, + } + ], + }, + ) + + await async_client.broadcasts.list( + channel_id="550e8400-e29b-41d4-a716-446655440000", + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.url.path == "/broadcasts" diff --git a/tests/test_channels.py b/tests/test_channels.py new file mode 100644 index 0000000..6a93461 --- /dev/null +++ b/tests/test_channels.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from pytest_httpx import HTTPXMock + +import sdk_helo_email as helo + + +def test_list(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="GET", + json={"results": [{}], "totalCount": 1}, + ) + + client.channels.list() + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "GET" + assert request.url.path == "/channels" + + +def test_create(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="POST", + json={}, + ) + + client.channels.create( + name="test-name", + delivery_type=helo.DeliveryType.LIVE, + tracking={"links": True, "opens": True}, + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "POST" + assert request.url.path == "/channels" + + +def test_retrieve(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="GET", + json={}, + ) + + client.channels.retrieve( + "550e8400-e29b-41d4-a716-446655440000", + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "GET" + assert request.url.path == "/channels/550e8400-e29b-41d4-a716-446655440000" + + +def test_update(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="PATCH", + json={}, + ) + + client.channels.update( + "550e8400-e29b-41d4-a716-446655440000", + name="test-name", + delivery_type=helo.DeliveryType.LIVE, + tracking={"links": True, "opens": True}, + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "PATCH" + assert request.url.path == "/channels/550e8400-e29b-41d4-a716-446655440000" + + +def test_delete(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="DELETE", + status_code=204, + ) + + client.channels.delete( + "550e8400-e29b-41d4-a716-446655440000", + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "DELETE" + assert request.url.path == "/channels/550e8400-e29b-41d4-a716-446655440000" + + +async def test_list_async(async_client: helo.AsyncHelo, httpx_mock: HTTPXMock) -> None: + """The async resource is generated from the same operation, so one call proves the pair.""" + httpx_mock.add_response( + method="GET", + json={"results": [{}], "totalCount": 1}, + ) + + await async_client.channels.list() + + request = httpx_mock.get_request() + assert request is not None + assert request.url.path == "/channels" diff --git a/tests/test_client.py b/tests/test_client.py deleted file mode 100644 index 44eb9a0..0000000 --- a/tests/test_client.py +++ /dev/null @@ -1,285 +0,0 @@ -from __future__ import annotations - -import json as json_lib - -import pytest -from pytest_httpx import HTTPXMock - -import sdk_helo_email -from sdk_helo_email import AsyncHelo, DeliveryType, Helo, WebhookEvent - -BASE_URL = "http://localhost:8000" - - -@pytest.fixture -def client(httpx_mock: HTTPXMock) -> Helo: - return Helo(api_key="test-key", base_url=BASE_URL) - - -@pytest.fixture -def async_client(httpx_mock: HTTPXMock) -> AsyncHelo: - return AsyncHelo(api_key="test-key", base_url=BASE_URL) - - -class TestChannels: - def test_create(self, client: Helo, httpx_mock: HTTPXMock) -> None: - httpx_mock.add_response( - method="POST", - url=f"{BASE_URL}/channels", - json={ - "id": "channel-1", - "name": "test", - "deliveryType": "live", - "createdAt": "2024-01-01T00:00:00Z", - "updatedAt": "2024-01-01T00:00:00Z", - "tracking": {"links": True, "opens": True}, - }, - ) - result = client.channels.create(name="test", delivery_type=DeliveryType.LIVE) - assert result.id == "channel-1" - assert result.name == "test" - assert result.delivery_type == DeliveryType.LIVE - assert result.tracking.links is True - - def test_list(self, client: Helo, httpx_mock: HTTPXMock) -> None: - httpx_mock.add_response( - method="GET", - url=f"{BASE_URL}/channels", - json={ - "results": [ - { - "id": "channel-1", - "name": "test", - "deliveryType": "live", - "createdAt": "2024-01-01T00:00:00Z", - } - ], - "totalCount": 1, - }, - ) - result = client.channels.list() - assert result.total_count == 1 - assert len(result.results) == 1 - assert result.results[0].id == "channel-1" - - def test_list_with_channel_ids(self, client: Helo, httpx_mock: HTTPXMock) -> None: - httpx_mock.add_response( - method="GET", - url=f"{BASE_URL}/channels?channelIds=a,b", - json={"results": [], "totalCount": 0}, - ) - client.channels.list(channel_ids=["a", "b"]) - request = httpx_mock.get_request() - assert request is not None - assert request.url.params["channelIds"] == "a,b" - - def test_retrieve(self, client: Helo, httpx_mock: HTTPXMock) -> None: - httpx_mock.add_response( - method="GET", - url=f"{BASE_URL}/channels/channel-1", - json={ - "id": "channel-1", - "name": "test", - "deliveryType": "sandbox", - "createdAt": "2024-01-01T00:00:00Z", - "updatedAt": "2024-01-01T00:00:00Z", - "tracking": {"links": False, "opens": True}, - }, - ) - result = client.channels.retrieve("channel-1") - assert result.id == "channel-1" - assert result.delivery_type == DeliveryType.SANDBOX - - def test_delete(self, client: Helo, httpx_mock: HTTPXMock) -> None: - httpx_mock.add_response( - method="DELETE", url=f"{BASE_URL}/channels/channel-1", status_code=204 - ) - client.channels.delete("channel-1") - - -class TestSending: - def test_transactional(self, client: Helo, httpx_mock: HTTPXMock) -> None: - httpx_mock.add_response( - method="POST", - url=f"{BASE_URL}/send/transactional", - json={"status": "accepted", "messageId": "msg-1", "suppressions": []}, - ) - result = client.sending.transactional( - from_={"email": "sender@example.com", "name": "Sender"}, - to=[{"email": "recipient@example.com"}], - subject="Hello", - html="

Hello

", - ) - assert result.status == "accepted" - assert result.message_id == "msg-1" - - def test_transactional_body_uses_api_keys(self, client: Helo, httpx_mock: HTTPXMock) -> None: - httpx_mock.add_response( - method="POST", - url=f"{BASE_URL}/send/transactional", - json={"status": "accepted", "messageId": "msg-1"}, - ) - client.sending.transactional( - from_={"email": "sender@example.com"}, - to=[{"email": "recipient@example.com"}], - reply_to=[{"email": "reply@example.com"}], - subject="Hello", - ) - request = httpx_mock.get_request() - assert request is not None - body = json_lib.loads(request.content) - # from_ -> "from", reply_to -> "replyTo" - assert body["from"] == {"email": "sender@example.com"} - assert body["replyTo"] == [{"email": "reply@example.com"}] - - def test_transactional_with_channel_id(self, client: Helo, httpx_mock: HTTPXMock) -> None: - httpx_mock.add_response( - method="POST", - url=f"{BASE_URL}/send/transactional", - json={"status": "accepted", "messageId": "msg-2"}, - ) - result = client.sending.transactional( - from_={"email": "sender@example.com"}, - to=[{"email": "recipient@example.com"}], - subject="Test", - channel_id="channel-1", - idempotency_key="unique-key-123", - ) - assert result.message_id == "msg-2" - request = httpx_mock.get_request() - assert request is not None - assert request.headers.get("X-Helo-Channel-Id") == "channel-1" - assert request.headers.get("X-Helo-Idempotency-Key") == "unique-key-123" - - -class TestErrors: - def test_authentication_error(self, httpx_mock: HTTPXMock) -> None: - client = Helo(api_key="test-key", base_url=BASE_URL) - httpx_mock.add_response( - method="GET", - url=f"{BASE_URL}/channels", - status_code=401, - json={"title": "Unauthorized", "status": 401, "code": "unauthorized"}, - ) - with pytest.raises(sdk_helo_email.AuthenticationError) as exc_info: - client.channels.list() - assert exc_info.value.status_code == 401 - assert exc_info.value.error_code == "unauthorized" - - def test_not_found_error(self, httpx_mock: HTTPXMock) -> None: - client = Helo(api_key="test-key", base_url=BASE_URL) - httpx_mock.add_response( - method="GET", - url=f"{BASE_URL}/channels/missing", - status_code=404, - json={"title": "Not Found", "status": 404}, - ) - with pytest.raises(sdk_helo_email.NotFoundError): - client.channels.retrieve("missing") - - def test_conflict_error(self, httpx_mock: HTTPXMock) -> None: - client = Helo(api_key="test-key", base_url=BASE_URL, max_retries=0) - httpx_mock.add_response( - method="POST", - url=f"{BASE_URL}/channels", - status_code=409, - json={"title": "Conflict", "status": 409}, - ) - with pytest.raises(sdk_helo_email.ConflictError): - client.channels.create(name="dupe", delivery_type=DeliveryType.LIVE) - - def test_rate_limit_error_exposes_retry_after(self, httpx_mock: HTTPXMock) -> None: - client = Helo(api_key="test-key", base_url=BASE_URL, max_retries=0) - httpx_mock.add_response( - method="GET", - url=f"{BASE_URL}/channels", - status_code=429, - headers={"Retry-After": "12"}, - json={"title": "Too Many Requests", "status": 429}, - ) - with pytest.raises(sdk_helo_email.RateLimitError) as exc_info: - client.channels.list() - assert exc_info.value.status_code == 429 - assert exc_info.value.retry_after == 12.0 - - -class TestRetries: - def test_retries_on_server_error( - self, httpx_mock: HTTPXMock, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.setattr(sdk_helo_email._http, "_backoff_delay", lambda *a, **k: 0.0) - client = Helo(api_key="test-key", base_url=BASE_URL, max_retries=2) - httpx_mock.add_response(method="GET", url=f"{BASE_URL}/channels", status_code=503) - httpx_mock.add_response( - method="GET", - url=f"{BASE_URL}/channels", - json={"results": [], "totalCount": 0}, - ) - result = client.channels.list() - assert result.total_count == 0 - assert len(httpx_mock.get_requests()) == 2 - - def test_gives_up_after_max_retries( - self, httpx_mock: HTTPXMock, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.setattr(sdk_helo_email._http, "_backoff_delay", lambda *a, **k: 0.0) - client = Helo(api_key="test-key", base_url=BASE_URL, max_retries=1) - httpx_mock.add_response(method="GET", url=f"{BASE_URL}/channels", status_code=500) - httpx_mock.add_response(method="GET", url=f"{BASE_URL}/channels", status_code=500) - with pytest.raises(sdk_helo_email.InternalServerError): - client.channels.list() - assert len(httpx_mock.get_requests()) == 2 - - -class TestWebhookEndpoints: - def test_create(self, client: Helo, httpx_mock: HTTPXMock) -> None: - httpx_mock.add_response( - method="POST", - url=f"{BASE_URL}/webhook-endpoints", - json={ - "id": "wh-1", - "url": "https://example.com/webhook", - "events": ["delivered", "bounced"], - "enabled": True, - "payloadSigningKey": "signing-key-abc", - }, - ) - result = client.webhook_endpoints.create( - url="https://example.com/webhook", - events=[WebhookEvent.DELIVERED, WebhookEvent.BOUNCED], - ) - assert result.id == "wh-1" - assert result.enabled is True - - -class TestAsyncClient: - async def test_create_channel(self, async_client: AsyncHelo, httpx_mock: HTTPXMock) -> None: - httpx_mock.add_response( - method="POST", - url=f"{BASE_URL}/channels", - json={ - "id": "channel-1", - "name": "test", - "deliveryType": "live", - "createdAt": "2024-01-01T00:00:00Z", - "updatedAt": "2024-01-01T00:00:00Z", - "tracking": {"links": True, "opens": True}, - }, - ) - async with async_client as client: - result = await client.channels.create(name="test", delivery_type=DeliveryType.LIVE) - assert result.id == "channel-1" - assert result.delivery_type == DeliveryType.LIVE - - async def test_async_not_found_error( - self, async_client: AsyncHelo, httpx_mock: HTTPXMock - ) -> None: - httpx_mock.add_response( - method="GET", - url=f"{BASE_URL}/channels/missing", - status_code=404, - json={"title": "Not Found", "status": 404}, - ) - async with async_client as client: - with pytest.raises(sdk_helo_email.NotFoundError): - await client.channels.retrieve("missing") diff --git a/tests/test_domains.py b/tests/test_domains.py new file mode 100644 index 0000000..0867bbe --- /dev/null +++ b/tests/test_domains.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +from pytest_httpx import HTTPXMock + +import sdk_helo_email as helo + + +def test_list(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="GET", + json={ + "totalCount": 1, + "results": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "createdAt": "2024-01-01T00:00:00Z", + "name": "example", + "verified": True, + } + ], + }, + ) + + client.domains.list() + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "GET" + assert request.url.path == "/domains" + + +def test_create(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="POST", + json={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "createdAt": "2024-01-01T00:00:00Z", + "name": "example", + "verified": True, + "dnsRecords": {}, + }, + ) + + client.domains.create( + name="test-name", + channel_ids=["550e8400-e29b-41d4-a716-446655440000"], + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "POST" + assert request.url.path == "/domains" + + +def test_retrieve(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="GET", + json={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "createdAt": "2024-01-01T00:00:00Z", + "name": "example", + "verified": True, + "dnsRecords": {}, + }, + ) + + client.domains.retrieve( + "550e8400-e29b-41d4-a716-446655440000", + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "GET" + assert request.url.path == "/domains/550e8400-e29b-41d4-a716-446655440000" + + +def test_update(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="PATCH", + json={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "createdAt": "2024-01-01T00:00:00Z", + "name": "example", + "verified": True, + }, + ) + + client.domains.update( + "550e8400-e29b-41d4-a716-446655440000", + channel_ids=["550e8400-e29b-41d4-a716-446655440000"], + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "PATCH" + assert request.url.path == "/domains/550e8400-e29b-41d4-a716-446655440000" + + +def test_delete(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="DELETE", + status_code=204, + ) + + client.domains.delete( + "550e8400-e29b-41d4-a716-446655440000", + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "DELETE" + assert request.url.path == "/domains/550e8400-e29b-41d4-a716-446655440000" + + +def test_verify(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="POST", + json={}, + ) + + client.domains.verify( + "550e8400-e29b-41d4-a716-446655440000", + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "POST" + assert request.url.path == "/domains/550e8400-e29b-41d4-a716-446655440000/verify" + + +def test_rotate_key(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="POST", + json={}, + ) + + client.domains.rotate_key( + "550e8400-e29b-41d4-a716-446655440000", + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "POST" + assert request.url.path == "/domains/550e8400-e29b-41d4-a716-446655440000/rotate-key" + + +async def test_list_async(async_client: helo.AsyncHelo, httpx_mock: HTTPXMock) -> None: + """The async resource is generated from the same operation, so one call proves the pair.""" + httpx_mock.add_response( + method="GET", + json={ + "totalCount": 1, + "results": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "createdAt": "2024-01-01T00:00:00Z", + "name": "example", + "verified": True, + } + ], + }, + ) + + await async_client.domains.list() + + request = httpx_mock.get_request() + assert request is not None + assert request.url.path == "/domains" diff --git a/tests/test_integration.py b/tests/test_integration.py deleted file mode 100644 index f7eede7..0000000 --- a/tests/test_integration.py +++ /dev/null @@ -1,473 +0,0 @@ -"""Integration tests that exercise the SDK against a live Helo API. - -These are skipped by default. Run them with:: - - pytest --integration - -with ``HELO_API_KEY`` set in the environment. They target a local instance at -``http://localhost:8000`` by default; override with ``HELO_BASE_URL``. - -Unlike the read-only smoke tests, this suite exercises the full surface area: -it creates, mutates, and deletes real resources, sends messages, and reads back -activity and statistics. Created resources use unique names and are cleaned up -in fixture teardown. - -The API is eventually consistent, so reads that follow a write are wrapped in -``eventually(...)``, which retries until the assertion passes or a timeout is -reached. Tune the timing with ``HELO_POLL_TIMEOUT`` / ``HELO_POLL_INTERVAL``. -""" - -from __future__ import annotations - -import os -import time -import uuid -from collections.abc import Callable, Iterator -from typing import TypeVar, Optional - -import pytest - -import sdk_helo_email -from sdk_helo_email import APIError -from tests.helpers.domains import InternalHttpClient - -pytestmark = pytest.mark.integration - -T = TypeVar("T") - -POLL_TIMEOUT = float(os.environ.get("HELO_POLL_TIMEOUT", "10")) -POLL_INTERVAL = float(os.environ.get("HELO_POLL_INTERVAL", "0.5")) - - -def eventually( - fn: Callable[[], T], - *, - catch: tuple[type[BaseException], ...] = (AssertionError,), - timeout: float = POLL_TIMEOUT, - interval: float = POLL_INTERVAL, -) -> T: - """Call ``fn`` until it returns without raising one of ``catch``. - - Retries on the listed exception types (assertion failures by default, so a - ``list``-membership check can be polled directly) until ``timeout`` seconds - have elapsed, then re-raises the last failure. - """ - deadline = time.monotonic() + timeout - while True: - try: - return fn() - except catch: - if time.monotonic() >= deadline: - raise - time.sleep(interval) - - -def _unique(prefix: str | None = None) -> str: - return f"{prefix}-{uuid.uuid4().hex[:12]}" if prefix else f"{uuid.uuid4().hex[:12]}" - - -# --------------------------------------------------------------------------- # -# Resource fixtures (create, wait for consistency, clean up) -# --------------------------------------------------------------------------- # - - -@pytest.fixture(scope="module") -def channel(live_client: sdk_helo_email.Helo) -> Iterator[sdk_helo_email.ChannelDetailsResponse]: - created = live_client.channels.create( - name=_unique("sdk-test"), - delivery_type=sdk_helo_email.DeliveryType.SANDBOX, - ) - # Wait until the new channel is readable before dependent tests use it. - eventually(lambda: live_client.channels.retrieve(created.id), catch=(sdk_helo_email.NotFoundError,)) - try: - yield created - finally: - try: - live_client.channels.delete(created.id) - except sdk_helo_email.NotFoundError: - pass - - -@pytest.fixture(scope="module") -def domain( - live_client: sdk_helo_email.Helo, channel: sdk_helo_email.ChannelDetailsResponse -) -> Iterator[sdk_helo_email.DomainWithDnsResponse]: - created = live_client.domains.create( - name=f"{_unique('mail')}.{_unique()}-test.com", - channel_ids=[channel.id], - ) - internal_client = InternalHttpClient() - internal_client.verify_domain(created.id) - eventually(lambda: live_client.domains.retrieve(created.id), catch=(sdk_helo_email.NotFoundError,)) - try: - yield created - finally: - try: - live_client.domains.delete(created.id) - except sdk_helo_email.NotFoundError: - pass - - -@pytest.fixture(scope="module") -def webhook( - live_client: sdk_helo_email.Helo, channel: sdk_helo_email.ChannelDetailsResponse -) -> Iterator[sdk_helo_email.WebhookEndpointResponse]: - created = live_client.webhook_endpoints.create( - url="https://example.com/webhooks/helo", - events=[sdk_helo_email.WebhookEvent.DELIVERED, sdk_helo_email.WebhookEvent.BOUNCED], - channel_id=channel.id, - ) - eventually( - lambda: live_client.webhook_endpoints.retrieve(created.id), - catch=(sdk_helo_email.NotFoundError,), - ) - try: - yield created - finally: - try: - live_client.webhook_endpoints.delete(created.id) - except sdk_helo_email.NotFoundError: - pass - - -@pytest.fixture(scope="module") -def broadcast_id(live_client: sdk_helo_email.Helo, channel: sdk_helo_email.ChannelDetailsResponse, domain: sdk_helo_email.DomainWithDnsResponse) -> str: - resp = live_client.sending.broadcast( - from_={"email": f"newsletter@{domain.name}"}, - template={"subject": "Hello {{name}}", "html": "

Hi {{name}}

"}, - messages=[ - {"to": [{"email": f"user1@{domain.name}"}], "variables": {"name": "Alice"}}, - {"to": [{"email": f"user2@{domain.name}"}], "variables": {"name": "Bob"}}, - ], - channel_id=channel.id, - ) - assert resp.broadcast_id - bid = resp.broadcast_id - eventually(lambda: live_client.broadcasts.retrieve(bid), catch=(sdk_helo_email.NotFoundError,)) - return bid - - -# --------------------------------------------------------------------------- # -# Channels -# --------------------------------------------------------------------------- # - - -class TestChannels: - def test_create_and_retrieve( - self, live_client: sdk_helo_email.Helo, channel: sdk_helo_email.ChannelDetailsResponse - ) -> None: - fetched = live_client.channels.retrieve(channel.id) - assert fetched.id == channel.id - assert fetched.delivery_type == sdk_helo_email.DeliveryType.SANDBOX - - def test_update(self, live_client: sdk_helo_email.Helo, channel: sdk_helo_email.ChannelDetailsResponse) -> None: - new_name = _unique("sdk-renamed") - updated = live_client.channels.update(channel.id, name=new_name) - assert updated.name == new_name - - def test_list_and_filter( - self, live_client: sdk_helo_email.Helo, channel: sdk_helo_email.ChannelDetailsResponse - ) -> None: - def listed() -> None: - page = live_client.channels.list(limit=50) - assert any(c.id == channel.id for c in page.results) - - eventually(listed) - - filtered = live_client.channels.list(channel_ids=[channel.id]) - assert all(c.id == channel.id for c in filtered.results) - - def test_create_delete_roundtrip(self, live_client: sdk_helo_email.Helo) -> None: - created = live_client.channels.create( - name=_unique("sdk-ephemeral"), - delivery_type=sdk_helo_email.DeliveryType.SANDBOX, - ) - live_client.channels.delete(created.id) - - def gone() -> None: - try: - live_client.channels.retrieve(created.id) - except sdk_helo_email.NotFoundError: - return - raise AssertionError("channel still present after delete") - - eventually(gone) - - -# --------------------------------------------------------------------------- # -# Sending -# --------------------------------------------------------------------------- # - - -class TestSending: - def test_transactional( - self, live_client: sdk_helo_email.Helo, channel: sdk_helo_email.ChannelDetailsResponse, domain: sdk_helo_email.DomainWithDnsResponse - ) -> None: - resp = live_client.sending.transactional( - from_={"email": f"sender@{domain.name}", "name": "SDK Test"}, - to=[{"email": f"recipient@{domain.name}"}], - subject="Integration test", - html="

Hello

", - text="Hello", - tags=["integration"], - channel_id=channel.id, - idempotency_key=_unique("idem"), - ) - assert resp.message_id - - def test_transactional_batch( - self, live_client: sdk_helo_email.Helo, channel: sdk_helo_email.ChannelDetailsResponse, domain: sdk_helo_email.DomainWithDnsResponse - ) -> None: - resp = live_client.sending.transactional_batch( - requests=[ - { - "from": {"email": f"sender@{domain.name}"}, - "to": [{"email": f"user1@{domain.name}"}], - "subject": "Batch 1", - "text": "Hi 1", - }, - { - "from": {"email": f"sender@{domain.name}"}, - "to": [{"email": f"user2@{domain.name}"}], - "subject": "Batch 2", - "text": "Hi 2", - }, - ], - channel_id=channel.id, - ) - assert len(resp.responses) == 2 - - def test_broadcast_message( - self, live_client: sdk_helo_email.Helo, channel: sdk_helo_email.ChannelDetailsResponse, domain: sdk_helo_email.DomainWithDnsResponse - ) -> None: - resp = live_client.sending.broadcast_message( - from_={"email": f"newsletter@{domain.name}"}, - to=[{"email": f"user@{domain.name}"}], - subject="Digest", - html="

News

", - channel_id=channel.id, - ) - assert resp.message_id - - -# --------------------------------------------------------------------------- # -# Activity -# --------------------------------------------------------------------------- # - - -class TestActivity: - def test_list_messages( - self, live_client: sdk_helo_email.Helo, channel: sdk_helo_email.ChannelDetailsResponse - ) -> None: - result = live_client.activity.list_messages(channel_id=channel.id, limit=10) - assert isinstance(result.results, list) - - def test_list_events( - self, live_client: sdk_helo_email.Helo, channel: sdk_helo_email.ChannelDetailsResponse - ) -> None: - result = live_client.activity.list_events( - channel_id=channel.id, - event_types=[sdk_helo_email.EventType.ACCEPTED, sdk_helo_email.EventType.DELIVERED], - limit=10, - ) - assert isinstance(result.results, list) - - def test_send_then_retrieve_message( - self, live_client: sdk_helo_email.Helo, channel: sdk_helo_email.ChannelDetailsResponse, domain: sdk_helo_email.DomainWithDnsResponse - ) -> None: - sent = live_client.sending.transactional( - from_={"email": f"sender@{domain.name}"}, - to=[{"email": f"lookup@{domain.name}"}], - subject="Lookup", - text="Lookup", - channel_id=channel.id, - ) - message_id = sent.message_id - assert message_id - message = eventually( - lambda: live_client.activity.retrieve_message(message_id), - catch=(sdk_helo_email.NotFoundError,), - ) - assert message.message_id == message_id - - -# --------------------------------------------------------------------------- # -# Domains -# --------------------------------------------------------------------------- # - - -class TestDomains: - def test_retrieve(self, live_client: sdk_helo_email.Helo, domain: sdk_helo_email.DomainWithDnsResponse) -> None: - fetched = live_client.domains.retrieve(domain.id) - assert fetched.id == domain.id - - def test_list(self, live_client: sdk_helo_email.Helo, domain: sdk_helo_email.DomainWithDnsResponse) -> None: - def listed() -> None: - page = live_client.domains.list(limit=50) - assert any(d.id == domain.id for d in page.results) - - eventually(listed) - - def test_update_channels( - self, - live_client: sdk_helo_email.Helo, - domain: sdk_helo_email.DomainWithDnsResponse, - channel: sdk_helo_email.ChannelDetailsResponse, - ) -> None: - updated = live_client.domains.update(domain.id, channel_ids=[channel.id]) - assert updated.id == domain.id - - def test_verify(self, live_client: sdk_helo_email.Helo, domain: sdk_helo_email.DomainWithDnsResponse) -> None: - records = live_client.domains.verify(domain.id) - assert records is not None - - def test_rotate_key( - self, live_client: sdk_helo_email.Helo, domain: sdk_helo_email.DomainWithDnsResponse - ) -> None: - record = live_client.domains.rotate_key(domain.id) - assert record is not None - - -# --------------------------------------------------------------------------- # -# Broadcasts -# --------------------------------------------------------------------------- # - - -class TestBroadcasts: - def test_list( - self, live_client: sdk_helo_email.Helo, channel: sdk_helo_email.ChannelDetailsResponse, broadcast_id: str - ) -> None: - def listed() -> None: - page = live_client.broadcasts.list(channel_id=channel.id, limit=50) - assert any(b.id == broadcast_id for b in page.results) - - eventually(listed) - - def test_retrieve(self, live_client: sdk_helo_email.Helo, broadcast_id: str) -> None: - broadcast = live_client.broadcasts.retrieve(broadcast_id) - assert broadcast.id == broadcast_id - - def test_list_failures(self, live_client: sdk_helo_email.Helo, broadcast_id: str) -> None: - failures = live_client.broadcasts.list_failures(broadcast_id) - assert isinstance(failures.results, list) - - def test_list_suppressions(self, live_client: sdk_helo_email.Helo, broadcast_id: str) -> None: - suppressions = live_client.broadcasts.list_suppressions(broadcast_id) - assert isinstance(suppressions.results, list) - - -# --------------------------------------------------------------------------- # -# Statistics -# --------------------------------------------------------------------------- # - - -class TestStatistics: - def test_totals(self, live_client: sdk_helo_email.Helo, channel: sdk_helo_email.ChannelDetailsResponse) -> None: - totals = live_client.statistics.retrieve_totals( - from_="2024-10-01T00:00:00Z", to="2024-12-31T23:59:59Z", channel_id=channel.id - ) - assert totals is not None - - def test_daily(self, live_client: sdk_helo_email.Helo, channel: sdk_helo_email.ChannelDetailsResponse) -> None: - daily = live_client.statistics.retrieve_daily( - from_="2024-11-01", - to="2024-12-31", - timezone="America/New_York", - channel_id=channel.id, - ) - assert daily is not None - - def test_hourly(self, live_client: sdk_helo_email.Helo, channel: sdk_helo_email.ChannelDetailsResponse) -> None: - hourly = live_client.statistics.retrieve_hourly( - from_="2024-01-01T00:00:00Z", - to="2024-01-01T23:59:59Z", - channel_id=channel.id, - ) - assert hourly is not None - - -# --------------------------------------------------------------------------- # -# Suppressions -# --------------------------------------------------------------------------- # - - -class TestSuppressions: - def test_create_list_remove( - self, live_client: sdk_helo_email.Helo, channel: sdk_helo_email.ChannelDetailsResponse, domain: sdk_helo_email.DomainWithDnsResponse - ) -> None: - email = f"{_unique('suppressed')}@{domain.name}" - - def create_suppression() -> None: - live_client.suppressions.create( - channel_id=channel.id, - mail_type=sdk_helo_email.MailType.TRANSACTIONAL, - emails=[email], - ) - - def present() -> None: - page = live_client.suppressions.list( - channel_id=channel.id, mail_type=sdk_helo_email.MailType.TRANSACTIONAL, email=email - ) - assert any(s.email == email for s in page.results) - - eventually(create_suppression) - eventually(present) - - live_client.suppressions.remove( - channel_id=channel.id, - mail_type=sdk_helo_email.MailType.TRANSACTIONAL, - emails=[email], - ) - - def absent() -> None: - page = live_client.suppressions.list( - channel_id=channel.id, mail_type=sdk_helo_email.MailType.TRANSACTIONAL, email=email - ) - assert all(s.email != email for s in page.results) - - eventually(absent) - - -# --------------------------------------------------------------------------- # -# Webhook endpoints -# --------------------------------------------------------------------------- # - - -class TestWebhookEndpoints: - def test_create_and_retrieve( - self, live_client: sdk_helo_email.Helo, webhook: sdk_helo_email.WebhookEndpointResponse - ) -> None: - fetched = live_client.webhook_endpoints.retrieve(webhook.id) - assert fetched.id == webhook.id - - def test_list(self, live_client: sdk_helo_email.Helo, webhook: sdk_helo_email.WebhookEndpointResponse) -> None: - def listed() -> None: - page = live_client.webhook_endpoints.list(limit=50) - assert any(w.id == webhook.id for w in page.results) - - eventually(listed) - - def test_update( - self, live_client: sdk_helo_email.Helo, webhook: sdk_helo_email.WebhookEndpointResponse - ) -> None: - updated = live_client.webhook_endpoints.update(webhook.id, enabled=False) - assert updated.enabled is False - - def test_regenerate_signing_key( - self, live_client: sdk_helo_email.Helo, webhook: sdk_helo_email.WebhookEndpointResponse - ) -> None: - rotated = live_client.webhook_endpoints.regenerate_signing_key(webhook.id) - assert rotated.id == webhook.id - - -# --------------------------------------------------------------------------- # -# Auth -# --------------------------------------------------------------------------- # - - -class TestAuth: - def test_invalid_key_raises(self) -> None: - base_url = os.environ.get("HELO_BASE_URL", "http://localhost:8000") - client = sdk_helo_email.Helo(api_key="obviously-invalid-key", base_url=base_url) - with pytest.raises(sdk_helo_email.AuthenticationError): - client.channels.list(limit=1) diff --git a/tests/test_sending.py b/tests/test_sending.py new file mode 100644 index 0000000..030aa23 --- /dev/null +++ b/tests/test_sending.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +from pytest_httpx import HTTPXMock + +import sdk_helo_email as helo + + +def test_transactional(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="POST", + json={}, + ) + + client.sending.transactional( + from_={"email": "from@yourdomain.com", "name": "From name"}, + to=[{"email": "to@example.com", "name": "To name"}], + cc=[{"email": "cc@example.com", "name": "Cc name"}], + bcc=[{"email": "bcc@example.com", "name": "Bcc name"}], + reply_to=[{"email": "reply-to@example.com", "name": "Reply-To name"}], + subject="Hello from Helo", + html="

Hi there, new friend.

This is a test message, delivered with <3 by Helo.

", + text="This is a test message, delivered with <3 by Helo.", + template={ + "subject": "test-subject", + "html": "test-html", + "text": "test-text", + "inlineStyles": True, + }, + tracking={"opens": True, "links": True}, + attachments=[ + { + "content": "SGVsbG8gd29ybGQ=", + "contentId": "test-contentId", + "contentType": "test-contentType", + "fileName": "test-fileName", + "disposition": helo.AttachmentDisposition.ATTACHMENT, + }, + ], + tags=["welcome", "onboarding"], + channel_id="550e8400-e29b-41d4-a716-446655440000", + idempotency_key="test-idempotency_key", + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "POST" + assert request.url.path == "/send/transactional" + assert "X-Helo-Channel-Id" in request.headers + assert "X-Helo-Idempotency-Key" in request.headers + + +def test_transactional_batch(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="POST", + json={"responses": [{"status": "example"}]}, + ) + + client.sending.transactional_batch( + requests=[ + { + "from": {"email": "from@yourdomain.com", "name": "From name"}, + "to": [{"email": "to@example.com", "name": "To name"}], + "subject": "Hello from Helo", + "html": "

Hi there, new friend.

This is a test message, delivered with <3 by Helo.

", + "text": "This is a test message, delivered with <3 by Helo.", + "tags": ["welcome", "onboarding"], + }, + ], + channel_id="550e8400-e29b-41d4-a716-446655440000", + idempotency_key="test-idempotency_key", + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "POST" + assert request.url.path == "/send/transactional/batch" + assert "X-Helo-Channel-Id" in request.headers + assert "X-Helo-Idempotency-Key" in request.headers + + +def test_broadcast(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="POST", + json={}, + ) + + client.sending.broadcast( + from_={"email": "test@example.com", "name": "test-name"}, + template={ + "subject": "test-subject", + "html": "test-html", + "text": "test-text", + "inlineStyles": True, + }, + messages=[ + { + "to": [{"email": "test@example.com", "name": "test-name"}], + "tags": ["test-tag"], + }, + ], + reply_to=[{"email": "test@example.com", "name": "test-name"}], + tracking={"opens": True, "links": True}, + attachments=[ + { + "content": "SGVsbG8gd29ybGQ=", + "contentId": "test-contentId", + "contentType": "test-contentType", + "fileName": "test-fileName", + "disposition": helo.AttachmentDisposition.ATTACHMENT, + }, + ], + tags=["test-tag"], + channel_id="550e8400-e29b-41d4-a716-446655440000", + idempotency_key="test-idempotency_key", + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "POST" + assert request.url.path == "/send/broadcast" + assert "X-Helo-Channel-Id" in request.headers + assert "X-Helo-Idempotency-Key" in request.headers + + +def test_broadcast_message(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="POST", + json={}, + ) + + client.sending.broadcast_message( + from_={"email": "from@yourdomain.com", "name": "From name"}, + to=[{"email": "to@example.com", "name": "To name"}], + cc=[{"email": "cc@example.com", "name": "Cc name"}], + bcc=[{"email": "bcc@example.com", "name": "Bcc name"}], + reply_to=[{"email": "reply-to@example.com", "name": "Reply-To name"}], + subject="Hello from Helo", + html="

Hi there, new friend.

This is a test message, delivered with <3 by Helo.

", + text="This is a test message, delivered with <3 by Helo.", + template={ + "subject": "test-subject", + "html": "test-html", + "text": "test-text", + "inlineStyles": True, + }, + tracking={"opens": True, "links": True}, + attachments=[ + { + "content": "SGVsbG8gd29ybGQ=", + "contentId": "test-contentId", + "contentType": "test-contentType", + "fileName": "test-fileName", + "disposition": helo.AttachmentDisposition.ATTACHMENT, + }, + ], + tags=["welcome", "onboarding"], + channel_id="550e8400-e29b-41d4-a716-446655440000", + idempotency_key="test-idempotency_key", + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "POST" + assert request.url.path == "/send/broadcast/message" + assert "X-Helo-Channel-Id" in request.headers + assert "X-Helo-Idempotency-Key" in request.headers + + +async def test_transactional_async(async_client: helo.AsyncHelo, httpx_mock: HTTPXMock) -> None: + """The async resource is generated from the same operation, so one call proves the pair.""" + httpx_mock.add_response( + method="POST", + json={}, + ) + + await async_client.sending.transactional( + from_={"email": "from@yourdomain.com", "name": "From name"}, + to=[{"email": "to@example.com", "name": "To name"}], + cc=[{"email": "cc@example.com", "name": "Cc name"}], + bcc=[{"email": "bcc@example.com", "name": "Bcc name"}], + reply_to=[{"email": "reply-to@example.com", "name": "Reply-To name"}], + subject="Hello from Helo", + html="

Hi there, new friend.

This is a test message, delivered with <3 by Helo.

", + text="This is a test message, delivered with <3 by Helo.", + template={ + "subject": "test-subject", + "html": "test-html", + "text": "test-text", + "inlineStyles": True, + }, + tracking={"opens": True, "links": True}, + attachments=[ + { + "content": "SGVsbG8gd29ybGQ=", + "contentId": "test-contentId", + "contentType": "test-contentType", + "fileName": "test-fileName", + "disposition": helo.AttachmentDisposition.ATTACHMENT, + }, + ], + tags=["welcome", "onboarding"], + channel_id="550e8400-e29b-41d4-a716-446655440000", + idempotency_key="test-idempotency_key", + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.url.path == "/send/transactional" diff --git a/tests/test_statistics.py b/tests/test_statistics.py new file mode 100644 index 0000000..c2ffded --- /dev/null +++ b/tests/test_statistics.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from pytest_httpx import HTTPXMock + +import sdk_helo_email as helo + + +def test_retrieve_hourly(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="GET", + json={}, + ) + + client.statistics.retrieve_hourly( + from_="2024-01-01T00:00:00Z", + to="2024-01-01T00:00:00Z", + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "GET" + assert request.url.path == "/statistics/hourly" + + +def test_retrieve_daily(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="GET", + json={}, + ) + + client.statistics.retrieve_daily( + from_="2024-01-01", + to="2024-01-01", + timezone="America/New_York", + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "GET" + assert request.url.path == "/statistics/daily" + + +def test_retrieve_totals(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="GET", + json={}, + ) + + client.statistics.retrieve_totals( + from_="2024-01-01T00:00:00Z", + to="2024-01-01T00:00:00Z", + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "GET" + assert request.url.path == "/statistics/totals" + + +async def test_retrieve_hourly_async(async_client: helo.AsyncHelo, httpx_mock: HTTPXMock) -> None: + """The async resource is generated from the same operation, so one call proves the pair.""" + httpx_mock.add_response( + method="GET", + json={}, + ) + + await async_client.statistics.retrieve_hourly( + from_="2024-01-01T00:00:00Z", + to="2024-01-01T00:00:00Z", + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.url.path == "/statistics/hourly" diff --git a/tests/test_suppressions.py b/tests/test_suppressions.py new file mode 100644 index 0000000..cd75d16 --- /dev/null +++ b/tests/test_suppressions.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from pytest_httpx import HTTPXMock + +import sdk_helo_email as helo + + +def test_list(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="GET", + json={ + "totalCount": 1, + "results": [ + { + "email": "example", + "reason": "bounce", + "createdAt": "2024-01-01T00:00:00Z", + } + ], + }, + ) + + client.suppressions.list( + channel_id="550e8400-e29b-41d4-a716-446655440000", + mail_type=helo.MailType.TRANSACTIONAL, + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "GET" + assert request.url.path == "/suppressions" + + +def test_create(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="POST", + json={"results": [{"email": "example", "success": True}]}, + ) + + client.suppressions.create( + channel_id="550e8400-e29b-41d4-a716-446655440000", + mail_type=helo.MailType.TRANSACTIONAL, + emails=["test@example.com"], + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "POST" + assert request.url.path == "/suppressions" + + +def test_remove(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="POST", + json={"results": [{"email": "example", "success": True}]}, + ) + + client.suppressions.remove( + channel_id="550e8400-e29b-41d4-a716-446655440000", + mail_type=helo.MailType.TRANSACTIONAL, + emails=["test@example.com"], + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "POST" + assert request.url.path == "/suppressions/remove" + + +async def test_list_async(async_client: helo.AsyncHelo, httpx_mock: HTTPXMock) -> None: + """The async resource is generated from the same operation, so one call proves the pair.""" + httpx_mock.add_response( + method="GET", + json={ + "totalCount": 1, + "results": [ + { + "email": "example", + "reason": "bounce", + "createdAt": "2024-01-01T00:00:00Z", + } + ], + }, + ) + + await async_client.suppressions.list( + channel_id="550e8400-e29b-41d4-a716-446655440000", + mail_type=helo.MailType.TRANSACTIONAL, + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.url.path == "/suppressions" diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py new file mode 100644 index 0000000..08f1729 --- /dev/null +++ b/tests/test_webhooks.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from pytest_httpx import HTTPXMock + +import sdk_helo_email as helo + + +def test_list(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="GET", + json={"results": [{}], "totalCount": 1}, + ) + + client.webhooks.list() + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "GET" + assert request.url.path == "/webhooks" + + +def test_create(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="POST", + json={}, + ) + + client.webhooks.create( + url="test-url", + events=[helo.WebhookEvent.ACCEPTED], + channel_id="550e8400-e29b-41d4-a716-446655440000", + additional_headers=[{"name": "test-name", "value": "test-value"}], + enabled=True, + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "POST" + assert request.url.path == "/webhooks" + + +def test_retrieve(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="GET", + json={}, + ) + + client.webhooks.retrieve( + "550e8400-e29b-41d4-a716-446655440000", + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "GET" + assert request.url.path == "/webhooks/550e8400-e29b-41d4-a716-446655440000" + + +def test_update(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="PATCH", + json={}, + ) + + client.webhooks.update( + "550e8400-e29b-41d4-a716-446655440000", + url="test-url", + events=[helo.WebhookEvent.ACCEPTED], + channel_id="550e8400-e29b-41d4-a716-446655440000", + additional_headers=[{"name": "test-name", "value": "test-value"}], + enabled=True, + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "PATCH" + assert request.url.path == "/webhooks/550e8400-e29b-41d4-a716-446655440000" + + +def test_delete(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="DELETE", + status_code=204, + ) + + client.webhooks.delete( + "550e8400-e29b-41d4-a716-446655440000", + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "DELETE" + assert request.url.path == "/webhooks/550e8400-e29b-41d4-a716-446655440000" + + +def test_regenerate_signing_key(client: helo.Helo, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="POST", + json={}, + ) + + client.webhooks.regenerate_signing_key( + "550e8400-e29b-41d4-a716-446655440000", + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "POST" + assert ( + request.url.path == "/webhooks/550e8400-e29b-41d4-a716-446655440000/regenerate-signing-key" + ) + + +async def test_list_async(async_client: helo.AsyncHelo, httpx_mock: HTTPXMock) -> None: + """The async resource is generated from the same operation, so one call proves the pair.""" + httpx_mock.add_response( + method="GET", + json={"results": [{}], "totalCount": 1}, + ) + + await async_client.webhooks.list() + + request = httpx_mock.get_request() + assert request is not None + assert request.url.path == "/webhooks"