Metadata-Version: 2.4
Name: senddy
Version: 0.3.3
Summary: Official Python SDK for Senddy.io
Project-URL: Homepage, https://github.com/senddy-io/senddy-python
Project-URL: Repository, https://github.com/senddy-io/senddy-python
License-Expression: MIT
Requires-Python: >=3.9
Requires-Dist: httpx<1,>=0.27
Provides-Extra: dev
Requires-Dist: datamodel-code-generator~=0.57.0; extra == 'dev'
Requires-Dist: mypy>=1.13; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: respx>=0.22; extra == 'dev'
Requires-Dist: ruff>=0.8; extra == 'dev'
Description-Content-Type: text/markdown

# Senddy Python SDK

Official Python SDK for the [Senddy.io](https://senddy.io) email API.

**[Full API reference →](./REFERENCE.md)** — every method, parameter, and return type.

## Installation

Requires **Python 3.9+**.

```bash
pip install senddy
```

## Quick Start

```python
from senddy import Senddy, SendEmailParams

client = Senddy("senddy_live_api_key")

result = client.emails.send(SendEmailParams(
    from_="sender@senddy.io",
    to="recipient@example.com",
    subject="Hello",
    html="<p>Welcome!</p>",
))

print(result.id)  # email_abc123
```

## Async Support

```python
from senddy import AsyncSenddy, SendEmailParams

async with AsyncSenddy("senddy_live_api_key") as client:
    result = await client.emails.send(SendEmailParams(
        from_="sender@senddy.io",
        to="recipient@example.com",
        subject="Hello",
        html="<p>Welcome!</p>",
    ))
```

## Configuration

All options are optional — sensible defaults are used if you don't override them.

```python
client = Senddy(
    "senddy_live_api_key",
    timeout=30.0,                   # seconds
    retries=2,
    headers={"X-Custom": "value"},  # extra headers on every request
)
```

If no API key is passed to the constructor, the SDK reads from the `SENDDY_API_KEY` environment variable.

Both clients support context managers for proper resource cleanup:

```python
with Senddy("senddy_live_api_key") as client:
    # client.close() called automatically on exit
    ...
```

## Emails

### Send

```python
from senddy import SendEmailParams, Attachment, RequestOptions

result = client.emails.send(
    SendEmailParams(
        from_="sender@senddy.io",
        to=["alice@example.com", "bob@example.com"],
        subject="Hello",
        html="<p>Hi there</p>",
        text="Hi there",                # optional plain-text fallback
        cc="cc@example.com",            # optional
        bcc="bcc@example.com",          # optional
        reply_to="reply@example.com",   # optional
        in_reply_to="<msg-1@senddy.io>",                 # optional: thread a reply (In-Reply-To)
        references=["<msg-0@senddy.io>"],                # optional: thread history (References)
        headers={"X-Campaign-Id": "welcome"},           # optional custom headers
        tags={"campaign": "welcome"},   # optional metadata
        attachments=[Attachment(        # optional
            filename="report.pdf",
            content=base64_string,
            content_type="application/pdf",
        )],
    ),
    # optional — send() already auto-generates a key per call; set this only to
    # dedupe the same send across separate calls (see Retries & idempotency).
    options=RequestOptions(idempotency_key="order-1234-receipt"),
)
```

> **Note:** The `from_` parameter uses a trailing underscore because `from` is a Python reserved word. The SDK maps it to `from` in the API request automatically.

> **Multiple recipients:** a send with several `to`/`cc` recipients is **one shared email** —
> everyone sees the full `To:`/`Cc:` list and reply-all threads for all of them (one shared
> `Message-ID`). `bcc` recipients receive it but are never shown to anyone. To keep recipients
> hidden from each other, send **separate single-recipient requests**. Combined `to`+`cc`+`bcc` ≤
> 50; each deliverable recipient is billed as one credit.

### Inline images (CID)

To embed an image in the body (rather than link to a hosted URL), use `inline_image()`. It
returns the `attachment` to send and the `src` to drop into your HTML — the `content_id` and
the `cid:` reference are derived together, so they can never drift:

```python
from senddy import SendEmailParams, inline_image

logo = inline_image(
    filename="logo.png",
    content=base64_string,  # base64-encoded image bytes
    content_type="image/png",
)

client.emails.send(
    SendEmailParams(
        from_="you@yourdomain.com",
        to="alice@example.com",
        subject="Welcome",
        html=f'<p>Welcome!</p><img src="{logo.src}">',  # logo.src == "cid:logo.png"
        attachments=[logo.attachment],
    )
)
```

The `content_id` defaults to the sanitized filename; pass an explicit `content_id` when
embedding two images that share a filename. Without the helper, set `disposition="inline"` and a
`content_id` on the `Attachment` yourself and reference the **same** token via
`<img src="cid:TOKEN">` (the API matches the two exactly — RFC 2392 — so they must be identical).

#### Embedding vs. hosting images

Inline (CID) embedding is not always the right choice — weigh it against a hosted `<img src="https://…">`:

- **Embedding** guarantees the image is present (no hotlink that can break or be blocked) and
  works offline, but it **increases message size** (base64 is ~33% larger than the raw bytes,
  and the bytes count against the 10MB-per-attachment / 40MB-total limits), can **raise spam
  scores** for large or image-heavy mail, and is still subject to the recipient's "show images"
  gate in some clients.
- **Hosting** keeps the message small and lets you swap/track the asset after sending, but the
  image won't render until the client fetches it (privacy/"show images" prompts, broken if the
  host is unreachable).

Rule of thumb: embed small, essential, static images (a logo, a signature); host large or
swappable assets. Either way, always provide meaningful `alt` text.

### Get

```python
email = client.emails.get("email_abc123")
print(email.status)      # 'delivered'
print(email.recipients)  # list of EmailRecipient
print(email.events)      # list of EmailEvent
```

### List

```python
from senddy import ListEmailsParams

result = client.emails.list(ListEmailsParams(
    limit=25,
    offset=0,
    status="delivered",
    since="2026-01-01T00:00:00Z",
))

for email in result.data:
    print(email.subject)
print(result.pagination.total)
```

### Get rendered content

```python
content = client.emails.get_content("email_abc123")
print(content.html)  # str or None
print(content.text)  # str or None
```

### Download EML

```python
download = client.emails.download("email_abc123")
# download.content is bytes containing the raw EML
# download.content_type is 'message/rfc822'
```

### Resend

```python
resent = client.emails.resend("email_abc123")
print(resent.id, resent.original_id)
```

## Suppressions

### List

```python
from senddy import ListSuppressionsParams

result = client.suppressions.list(ListSuppressionsParams(
    limit=50,
    search="example.com",
    reason="hard_bounce",
))
```

### Create

```python
from senddy import CreateSuppressionParams

entry = client.suppressions.create(CreateSuppressionParams(
    email_address="block@example.com",
))
```

### Delete

```python
result = client.suppressions.delete("block@example.com")
print(result.removed)  # True
```

### Export

```python
from senddy import ExportSuppressionsParams

export = client.suppressions.export(ExportSuppressionsParams(format="csv"))
# export.content is a raw string (CSV by default, or a JSON string for format="json")
```

## Domains

```python
from senddy import CreateDomainParams

# Add a sending domain
domain = client.domains.create(CreateDomainParams(domain="mail.example.com"))

# Trigger DNS verification. `verified` reflects ownership (the TXT challenge);
# spf_verified / dmarc_verified / dmarc_policy are advisory DNS observations
# reported regardless of ownership.
result = client.domains.verify(domain.id)
result.verified  # bool — ownership proven
result.spf_verified  # bool
result.dmarc_verified  # bool
result.dmarc_policy  # str | None — e.g. "reject"

# Add a subdomain under a verified root (inherits the root's verification)
from senddy import AddSubdomainParams

client.domains.add_subdomain(domain.id, AddSubdomainParams(subdomain="mail"))

# List, get, delete
client.domains.list()
client.domains.get(domain.id)
client.domains.delete(domain.id)

# Rotate DKIM keys
client.domains.regenerate_dkim(domain.id)

# Bulk DNS health check across all domains
client.domains.check_dns_health()
```

## Inbound Routes

```python
from senddy import CreateInboundRouteParams, UpdateInboundRouteParams

# Create a catchall route that forwards to your webhook
route = client.inbound_routes.create(CreateInboundRouteParams(
    match_type="catchall",
    webhook_url="https://your-app.example.com/inbound",
))

# List, get, update, delete
client.inbound_routes.list()
client.inbound_routes.get(route.id)
client.inbound_routes.update(route.id, UpdateInboundRouteParams(enabled=False))
client.inbound_routes.delete(route.id)

# Verify the MX record points at Senddy
client.inbound_routes.verify_mx(route.id)
```

See the [inbound docs](https://senddy.io/docs/inbound) for match patterns, webhook signing, and full configuration.

## Inbound Emails

```python
from senddy import ListInboundEmailsParams

result = client.inbound_emails.list(ListInboundEmailsParams(route_id=42, limit=50))

email = client.inbound_emails.get("inbound_xyz")
print(email.text_body, email.attachments)
```

## Webhook Endpoints

```python
from senddy import CreateWebhookEndpointParams, UpdateWebhookEndpointParams

# Register an endpoint to receive event callbacks. event_type is one of:
# sent, delivered, bounced, complained, opened, clicked, or "*" for all events.
result = client.webhook_endpoints.create(CreateWebhookEndpointParams(
    url="https://your-app.example.com/webhooks/senddy",
    domain="mail.example.com",
    event_type="delivered",
))
# Save result.secret — you'll need it to verify the signature on incoming webhooks.

# Test, list, update, delete
client.webhook_endpoints.test(result.id)
client.webhook_endpoints.list()
client.webhook_endpoints.update(result.id, UpdateWebhookEndpointParams(url="https://new-url.example.com"))
client.webhook_endpoints.delete(result.id)

# Inspect delivery history
deliveries = client.webhook_endpoints.deliveries(result.id)
```

### Verifying webhook signatures

`verify_webhook_signature` is a stateless helper (no API key needed) — call it from
your webhook handler with the raw request body and the `X-Webhook-*` headers. It
returns the parsed event on success and raises `WebhookVerificationError` on a bad
signature or a stale (replayed) timestamp.

```python
from senddy import verify_webhook_signature, WebhookVerificationError

# In your HTTP handler — pass the RAW body (str or bytes), not a re-serialized object.
try:
    event = verify_webhook_signature(
        payload=raw_body,
        signature=request.headers["X-Webhook-Signature"],
        timestamp=request.headers["X-Webhook-Timestamp"],
        secret=os.environ["SENDDY_WEBHOOK_SECRET"],
        # tolerance_seconds=300,  # optional replay window (default 300; 0 disables)
    )
    # event is the parsed payload (typed as `Any`)
except WebhookVerificationError:
    ...  # reject with 400 — do not process
```

### Typed inbound-email webhooks

For the **inbound-email** webhook (the body Senddy POSTs to your inbound route's
`webhook_url`), use the typed helper to get an `InboundEmailEvent` TypedDict:

```python
from senddy import (
    verify_inbound_email_webhook,
    WebhookVerificationError,
    InboundEmailEvent,
)

event: InboundEmailEvent = verify_inbound_email_webhook(
    payload=raw_body,
    signature=request.headers["X-Webhook-Signature"],
    timestamp=request.headers["X-Webhook-Timestamp"],
    secret=os.environ["SENDDY_INBOUND_SECRET"],  # per-route signing secret
)

# All fields autocompleted + type-checked under mypy / pyright:
event["schema_version"]              # Literal["2"]
event["from"]                        # str
event["spam_score"]                  # Optional[float]
event["threading"]["message_id"]     # Optional[str]
event["attachments"]                 # list[InboundEmailEventAttachment]
```

`InboundEmailEvent` is a `TypedDict` using the functional form because the
contract has a `from` key (a Python keyword). There is no runtime shape check;
the cast trusts the contract. The signature/timestamp/JSON-validity checks are
identical to `verify_webhook_signature`.

### Fetching inbound attachment bytes

Inbound webhook events carry attachment **metadata** only — bytes are fetched
on demand via `attachments[].download_url`, a self-contained capability URL
(Senddy-signed JWT in the query string). **No SDK helper is needed** — follow
the URL with any HTTP client:

```python
import httpx

attachment = event["attachments"][0]
bytes_data = httpx.get(attachment["download_url"]).content

# Or stream it:
# with httpx.stream("GET", attachment["download_url"]) as r:
#     for chunk in r.iter_bytes():
#         ...
```

No `Authorization` header is required. The URL is valid until
`attachment["expires_at"]` (24h on the free tier, 72h on paid). After that the
URL + bytes are both gone — there is no refresh path. If you process
attachments from a durable queue, persist `download_url` along with the event.

## Recipient Lists

Recipient allow-lists scope which addresses an API key may send to.

```python
from senddy import CreateRecipientListParams, UpdateRecipientListParams

# Create a list, then list / update / delete
rl = client.recipient_lists.create(
    CreateRecipientListParams(name="Internal", patterns=["*@example.com"])
)

result = client.recipient_lists.list()  # each item includes active_key_count
client.recipient_lists.update(rl.id, UpdateRecipientListParams(patterns=["*@example.com"]))
client.recipient_lists.delete(rl.id)
```

## Billing

```python
from senddy import (
    BillingUsageParams,
    ChangeTierParams,
    CreateCheckoutParams,
    ListBillingHistoryParams,
    UpdateAutoRefillParams,
)

# Reads
balance = client.billing.get_balance()
print(balance.credit_balance, balance.billing_tier)

client.billing.get_history(ListBillingHistoryParams(limit=50, type="deduction"))  # paginated
client.billing.get_payments()  # paginated
client.billing.get_auto_refill()
client.billing.get_tier()  # includes projected invoice on Pro
client.billing.get_usage(BillingUsageParams(days=30))

# Writes
session = client.billing.create_checkout(CreateCheckoutParams(credits=10000))
print(session.checkout_url)
client.billing.update_auto_refill(
    UpdateAutoRefillParams(enabled=True, threshold=1000, amount_cents=2000)
)
client.billing.change_tier(ChangeTierParams(tier="pro"))
```

## Error Handling

```python
from senddy import (
    APIError,
    ValidationError,
    RateLimitError,
    AuthenticationError,
)

try:
    client.emails.send(params)
except ValidationError as err:
    print(f"Validation: {err.details}")
except RateLimitError as err:
    print(f"Rate limited. Retry after {err.retry_after}s")
except AuthenticationError as err:
    print(f"Auth error: {err}")
except APIError as err:
    print(f"API error {err.status_code}: {err}")
```

### Error Types

| Class                 | Status | Description                                  |
| --------------------- | ------ | -------------------------------------------- |
| `ValidationError`     | 400    | Invalid request, includes `.details` list    |
| `AuthenticationError` | 401    | Missing or invalid API key                   |
| `ForbiddenError`      | 403    | Insufficient permissions                     |
| `NotFoundError`       | 404    | Resource not found                           |
| `RateLimitError`      | 429    | Rate limit exceeded, includes `.retry_after` |
| `InternalError`       | 5xx    | Server error                                 |

All error classes inherit from `APIError`, which inherits from `Exception`.

## Pagination

List methods return a result with `.data` and `.pagination` (`limit`, `offset`, `total`, `has_more`). Page through results by advancing `offset`:

```python
from senddy import ListEmailsParams

offset, limit = 0, 100
while True:
    result = client.emails.list(ListEmailsParams(limit=limit, offset=offset))
    for email in result.data:
        ...  # process email
    if not result.pagination.has_more:
        break
    offset += len(result.data)
```

Or let the SDK do the paging for you with `list_all`, a generator that fetches one page at a time (available on `emails`, `suppressions`, `domains`, and `inbound_emails`). `limit` sets the page size; filters are preserved across pages. The async client returns an async iterator:

```python
# Sync
for email in client.emails.list_all(ListEmailsParams(status="delivered", limit=100)):
    ...  # process every delivered email, fetched page by page

# Async
async for email in aclient.emails.list_all(ListEmailsParams(status="delivered")):
    ...
```

## Retries & idempotency

The SDK retries transient failures (HTTP 429, 5xx, network errors, and request timeouts) with exponential backoff and jitter, honoring the `Retry-After` header. Non-retryable errors (4xx other than 429) are raised immediately. Pass `retries=0` to the client to disable retries. The timeout is set by `timeout` on the client (seconds); override it per request with `RequestOptions(timeout=...)`.

To avoid sending a request twice, **only safe requests are retried automatically**:

- **Reads** (`GET`/`HEAD`/`OPTIONS`) are always retried.
- **Writes** (`POST`/`PUT`/`PATCH`/`DELETE`) are retried **only** when they carry an `Idempotency-Key`; otherwise they're attempted exactly once.
- **`emails.send()` is retry-safe by default** — it auto-generates one `Idempotency-Key` per call and reuses it across that call's retries, so a 429/5xx never produces a duplicate send.

Pass your own key to dedupe a send across _separate_ calls (e.g. a retry from your own task queue) — the server treats a repeat of the same key as the same request:

```python
from senddy import RequestOptions

client.emails.send(params, options=RequestOptions(idempotency_key="order-1234-receipt"))
```

## API coverage

The SDK wraps the full public API surface (every method exists on both `Senddy` and `AsyncSenddy`):

- `emails` — `send`, `get`, `list`, `get_content`, `download`, `resend`
- `suppressions` — `list`, `create`, `delete`, `export`
- `domains` — `list`, `get`, `create`, `add_subdomain`, `verify`, `delete`, `regenerate_dkim`, `check_dns_health`
- `inbound_routes`, `inbound_emails`, `webhook_endpoints`
- `recipient_lists` — `list`, `create`, `update`, `delete`
- `billing` — `get_balance`, `get_history`, `get_payments`, `get_auto_refill`, `get_tier`, `get_usage`, `create_checkout`, `update_auto_refill`, `change_tier`
- the stateless `verify_webhook_signature()` + `verify_inbound_email_webhook()` helpers

See the [API reference](./REFERENCE.md) for every method signature and type.

## Requirements

- Python >= 3.9
- Runtime dependency: [httpx](https://www.python-httpx.org/)

## Type Safety

The package ships with a `py.typed` marker (PEP 561) and all public types are fully annotated — works with mypy, pyright, and IDE autocompletion out of the box. Response models are generated from Senddy's OpenAPI specification, so they track the API exactly.

## License

MIT
