Metadata-Version: 2.4
Name: senddy
Version: 0.2.1
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: 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.

## 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
        tags={"campaign": "welcome"},   # optional metadata
        attachments=[Attachment(        # optional
            filename="report.pdf",
            content=base64_string,
            content_type="application/pdf",
        )],
    ),
    options=RequestOptions(idempotency_key="unique-key"),  # prevents duplicate sends
)
```

> **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.

### 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'
```

## 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
```

## Domains

```python
from senddy import CreateDomainParams

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

# Trigger DNS verification
client.domains.verify(domain.id)

# 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
result = client.webhook_endpoints.create(CreateWebhookEndpointParams(
    url="https://your-app.example.com/webhooks/senddy",
    domain="mail.example.com",
    event_type="email.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)
```

## Billing

```python
balance = client.billing.get_balance()
print(balance.credit_balance, balance.billing_tier)
```

## 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`.

## Retries

The SDK automatically retries on 429 and 5xx responses with exponential backoff and jitter. Retries respect the `Retry-After` header. Non-retryable errors (4xx except 429) are raised immediately.

## 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.

## License

MIT
