Metadata-Version: 2.1
Name: tsmart
Version: 1.1.1
Summary: Python client for tsmartSend, the email sending API of tsmart.ai
Home-page: https://tsmart.ai/docs/sdk/python
License: MIT
Requires-Python: >=3.8
Description-Content-Type: text/markdown

# tsmart

The Python client for **tsmartSend**, the email sending API of tsmart.ai (`https://send.tsmart.ai`).
Zero dependencies (the standard library's `urllib`), Python 3.8 or later, with type hints.
Full documentation: <https://tsmart.ai/docs/sdk/python>

## Install

```bash
pip install https://tsmart.ai/sdk/python/tsmart-1.1.1-py3-none-any.whl
```

In a `requirements.txt`:

```text
tsmart @ https://tsmart.ai/sdk/python/tsmart-1.1.1-py3-none-any.whl
```

The source archive is `https://tsmart.ai/sdk/python/tsmart-1.1.1.tar.gz`. SHA-256 checksums of every published file are
in <https://tsmart.ai/sdk/python/SHA256SUMS>; add `--hash=sha256:<checksum>` to the requirement line to have pip check it
(pip then expects a hash for every requirement in the file).

## Quick start

Create an API key in the console under **tsmartSend → API keys**, and send from a domain the company has verified.

```python
import os
from tsmart import Tsmart, TsmartError

client = Tsmart(os.environ["TSMART_API_KEY"])

try:
    sent = client.emails.send({
        "from": "Acme <hello@acme.com>",
        "to": ["jane@example.com"],
        "subject": "Welcome to Acme",
        "html": "<p>Thanks for signing up.</p>",
    })
    print("Email id:", sent["id"])
except TsmartError as e:
    print(e.status_code, e.name, e.message)
```

## Configuration

```python
client = Tsmart(api_key=None, base_url=None, timeout=30, max_retries=2)
```

| Argument | Default | |
| --- | --- | --- |
| `api_key` | `TSMART_API_KEY` environment variable | The API key `ts_…`. Without one the constructor raises `ValueError`. |
| `base_url` | `TSMART_BASE_URL`, else `https://send.tsmart.ai` | Must use `https` (plain `http` only for `localhost`, `127.0.0.1` and `[::1]`) and contain no credentials, query or fragment; anything else raises `ValueError`, so the API key never travels unencrypted. A trailing slash is removed. |
| `timeout` | `30` | Seconds to wait for the connection and for each read of the response. |
| `max_retries` | `2` | Retries of rate-limited requests, and of GET requests that failed on the network. |

## Responses and errors

Every method returns the API's JSON response as a `dict`, with the API's snake_case keys. When the API answers with an
error, the method raises `TsmartError`:

| Attribute | |
| --- | --- |
| `status_code` | The HTTP status, or `None` when no response arrived (`network_error`). |
| `name` | The error name, such as `validation_error` or `rate_limit_exceeded`. Check this one. |
| `message` | A human-readable explanation. It may change; do not parse it. |
| `headers` | The response headers with lower-case names (for example `ratelimit-remaining`). |

Programming errors (no API key, an empty id, a body that is not a dict) raise `ValueError` or `TypeError` before
anything is sent.

## Methods

Request bodies are plain dicts with the API's field names, so everything in <https://tsmart.ai/docs/api> applies as
written. The camelCase spellings `replyTo` and `scheduledAt`, and `contentType` and `contentId` on attachments, are
converted to the API names.

### Emails

```python
from pathlib import Path

sent = client.emails.send(
    {
        "from": "Acme Billing <billing@acme.com>",
        "to": "jane@example.com",
        "cc": ["accounts@example.com"],
        "reply_to": "support@acme.com",
        "subject": "Your invoice INV-1042",
        "html": "<p>Your invoice is attached.</p>",
        "text": "Your invoice is attached.",
        "headers": {"X-Entity-Ref-ID": "INV-1042"},
        "tags": [{"name": "category", "value": "invoice"}],
        "attachments": [
            {"filename": "INV-1042.pdf", "content": Path("INV-1042.pdf").read_bytes(), "content_type": "application/pdf"},
        ],
    },
    idempotency_key="invoice-INV-1042",
)
```

- `to`, `cc`, `bcc` and `reply_to` take one address or a list; at most 50 recipients across `to`, `cc` and `bcc`.
- `html` or `text` is required. Without `text`, a plain-text part is generated from the HTML; an empty `text` sends none.
- Attachment `content` is base64 text; `bytes` are encoded for you. Up to 20 attachments and 20 MB in total; add
  `content_id` to show an image inline with `cid:` in the HTML.
- `headers` may contain `X-…` headers, `List-Unsubscribe`, `List-Unsubscribe-Post`, `In-Reply-To` and `References`.
- `idempotency_key` (1 to 256 characters) is sent as the `Idempotency-Key` header: a retry with the same key and body
  within 24 hours returns the first response and sends nothing again.

Scheduling: `scheduled_at` takes ISO 8601 with a time zone, a timezone-aware `datetime`, or a phrase such as `in 10 min`,
`in 2 hours` or `in 1 day`, at most 30 days ahead. A scheduled email can be moved or canceled until it is sent.

```python
from datetime import datetime, timedelta, timezone

sent = client.emails.send({
    "from": "hello@acme.com",
    "to": "jane@example.com",
    "subject": "Your trial ends tomorrow",
    "text": "Upgrade any time from your account page.",
    "scheduled_at": "in 1 day",
})
istanbul = timezone(timedelta(hours=3))
client.emails.update(sent["id"], {"scheduled_at": datetime(2026, 10, 20, 9, 0, tzinfo=istanbul)})
client.emails.cancel(sent["id"])
```

Reading emails, their events and metrics:

```python
email = client.emails.get(email_id)          # with html, text (kept 3 days) and tags
print(email["last_event"])                   # sent, delivered, bounced, opened …

for e in client.emails.events(email_id)["data"]:
    print(e["created_at"], e["type"], e["recipient"])

recent = client.emails.list(limit=50)        # newest first

metrics = client.emails.metrics({
    "start_date": "2026-10-01",              # or a date / datetime
    "end_date": "2026-10-07",
    "timezone": "Europe/Istanbul",
    "granularity": "daily",
    "metrics": ["sent", "delivered", "bounced", "delivery_rate", "open_rate"],
    "dimensions": ["period"],
})
print(metrics["totals"])
for row in metrics["data"]:
    print(row["period"], row["sent"], row["delivered"])
```

### Batch

1 to 100 emails in one request, without attachments. `validation="strict"` (the default) refuses the whole batch when
one email is invalid; `"permissive"` sends the valid ones and lists the others in `errors` by index. It is sent as the
`x-batch-validation` header.

```python
result = client.batch.send(
    [
        {"from": "hello@acme.com", "to": "ana@example.com", "subject": "Your weekly digest", "html": "<p>…</p>"},
        {"from": "hello@acme.com", "to": "ben@example.com", "subject": "Your weekly digest", "html": "<p>…</p>"},
    ],
    idempotency_key="digest-2026-41",
    validation="permissive",
)
print(result["data"])     # [{"id": "…"}, {"id": "…"}]
print(result["errors"])   # [] or [{"index": 1, "message": "…"}]
```

### Domains

```python
domain = client.domains.create({"name": "acme.com"})
for r in domain["records"]:                  # the DNS records to publish
    print(r["type"], r["name"], r["value"])

client.domains.verify(domain["id"])          # proves ownership, then checks the sending records
print(client.domains.get(domain["id"])["status"])   # not_started, pending, partially_verified or verified

client.domains.update(domain["id"], {"open_tracking": True, "click_tracking": True})
client.domains.list()
client.domains.remove(domain["id"])
```

### API keys

```python
key = client.api_keys.create({"name": "Web shop", "permission": "sending_access", "domain_id": domain["id"]})
print(key["token"])                          # shown only once

client.api_keys.list()
client.api_keys.update(key["id"], {"name": "Web shop (production)"})
client.api_keys.remove(key["id"])            # revoked at once
```

The API has no endpoint that returns a single key, so there is no `api_keys.get`. A sending-access key can call
`emails.send` and `batch.send` only; everything else answers `401 restricted_api_key`.

### Webhooks

```python
hook = client.webhooks.create({
    "endpoint": "https://example.com/hooks/tsmart",
    "events": ["email.delivered", "email.bounced"],
})
print(hook["signing_secret"])                # whsec_…, keep it with your receiver

client.webhooks.test(hook["id"])             # queues an example email.sent event
client.webhooks.update(hook["id"], {"events": ["email.bounced"], "status": "enabled"})
client.webhooks.rotate_secret(hook["id"])    # the old secret stops working at once

events = client.webhooks.events.list(hook["id"])
event_id = events["data"][0]["id"]           # msg_…
client.webhooks.events.get(hook["id"], event_id)
client.webhooks.events.attempts(hook["id"], event_id)
client.webhooks.events.replay(hook["id"], event_id)

client.webhooks.remove(hook["id"])
```

### Suppressions

```python
client.suppressions.create({"email": "jane@example.com"})
print(client.suppressions.get("jane@example.com")["reason"])   # by id or address: bounce, complaint or manual
client.suppressions.list()
client.suppressions.remove("jane@example.com")
```

### Logs

```python
page = client.logs.list(limit=20)            # API requests of the last 30 days, newest first
for entry in page["data"]:
    print(entry["created_at"], entry["method"], entry["endpoint"], entry["response_status"])
print(client.logs.get(page["data"][0]["id"])["error"])   # the error name of a failed request, or None
```

### Mailboxes and alternate emails

Mailboxes are named by their address. They can only be created in the company's verified domains, and every change
follows the console's rules for the person who created the key (see [Permissions](#mailbox-and-group-permissions)).

```python
box = client.mailboxes.create({
    "email": "jane@acme.com",
    "first_name": "Jane",                    # or "name": "Jane Doe"
    "last_name": "Doe",
    "quota_mb": 2048,                        # optional: 256 to 51200, lowered to the plan's mailbox size
    "org_unit_id": 12,                       # optional: default the root unit
})
print(box["temporary_password"])             # shown only once; must be changed at the first sign-in

client.mailboxes.get("jane@acme.com")
client.mailboxes.list(domain="acme.com", limit=100)        # ordered by address
client.mailboxes.update("jane@acme.com", {"name": "Jane Smith", "quota_mb": 4096})
client.mailboxes.update("jane@acme.com", {"active": False})   # suspend; True restores
print(client.mailboxes.reset_password("jane@acme.com")["temporary_password"])
client.mailboxes.remove("jane@acme.com")     # with its mail and alternate emails; company owners only

# Alternate emails: mail to them reaches the mailbox, which may also send as them
client.mailboxes.aliases.create("jane@acme.com", {"address": "j.doe@acme.com"})
client.mailboxes.aliases.list("jane@acme.com")
client.mailboxes.aliases.remove("jane@acme.com", "j.doe@acme.com")
```

`update` takes `name`, `quota_mb`, `active` and `force_password_change`, at least one of them. A mailbox is returned as
`email`, `name`, `domain`, `active`, `quota_mb`, `used_bytes`, `messages`, `force_password_change`, `org_unit`
(`{"id", "name"}`), `aliases` and `last_sign_in_at`. A password or its hash is never returned, except the temporary
password of `create` and `reset_password`; keep it out of your logs.

### Groups

```python
client.groups.create({
    "address": "team@acme.com",
    "name": "Team",
    "members": ["jane@acme.com", "partner@example.org"],   # mailboxes or outside addresses, up to 200
})
client.groups.get("team@acme.com")
client.groups.list(domain="acme.com")
client.groups.update("team@acme.com", {"members": ["jane@acme.com", "sam@acme.com"]})   # the complete new list
client.groups.remove("team@acme.com")        # company owners only
```

A group is `{"object": "group", "address", "name", "members", "active"}`. `members` in `update` replaces the whole
list; `[]` removes every member, and a group without members discards its mail.

### Mailbox and group permissions

These methods need a full-access key, and the person who created the key needs the console privilege of the change:
View users (`list`, `get`, `aliases.list`), Create users (`create`), Update users (`name`, `quota_mb` and alternate
emails), Suspend users (`active`), Reset user passwords (`reset_password`, `force_password_change`), View groups and
Manage groups. A role limited to an organisational unit reaches only the mailboxes of that unit. Deleting a mailbox or a
group is the company owner's right.

### All methods

| Method | Request |
| --- | --- |
| `emails.send(payload, idempotency_key=None)` | `POST /emails` |
| `emails.get(email_id)` | `GET /emails/{id}` |
| `emails.list(limit=None, after=None, before=None)` | `GET /emails` |
| `emails.update(email_id, {"scheduled_at": …})` | `PATCH /emails/{id}` |
| `emails.cancel(email_id)` | `POST /emails/{id}/cancel` |
| `emails.events(email_id, limit=None, after=None, before=None)` | `GET /emails/{id}/events` |
| `emails.metrics(params=None)` | `GET /emails/metrics` |
| `batch.send(emails, idempotency_key=None, validation="strict")` | `POST /emails/batch` |
| `domains.create({"name": …})` | `POST /domains` |
| `domains.get(domain_id)` | `GET /domains/{id}` |
| `domains.list(limit=None, after=None, before=None)` | `GET /domains` |
| `domains.update(domain_id, {"open_tracking": …, "click_tracking": …})` | `PATCH /domains/{id}` |
| `domains.remove(domain_id)` | `DELETE /domains/{id}` |
| `domains.verify(domain_id)` | `POST /domains/{id}/verify` |
| `api_keys.create({"name": …, "permission": …, "domain_id": …})` | `POST /api-keys` |
| `api_keys.list(limit=None, after=None, before=None)` | `GET /api-keys` |
| `api_keys.update(api_key_id, {"name": …})` | `PATCH /api-keys/{id}` |
| `api_keys.remove(api_key_id)` | `DELETE /api-keys/{id}` |
| `webhooks.create({"endpoint": …, "events": […]})` | `POST /webhooks` |
| `webhooks.get(webhook_id)` | `GET /webhooks/{id}` |
| `webhooks.list(limit=None, after=None, before=None)` | `GET /webhooks` |
| `webhooks.update(webhook_id, {"endpoint": …, "events": …, "status": …})` | `PATCH /webhooks/{id}` |
| `webhooks.remove(webhook_id)` | `DELETE /webhooks/{id}` |
| `webhooks.rotate_secret(webhook_id)` | `POST /webhooks/{id}/signing-secret/rotate` |
| `webhooks.test(webhook_id)` | `POST /webhooks/{id}/test` |
| `webhooks.events.list(webhook_id, limit=None, after=None, before=None)` | `GET /webhooks/{id}/events` |
| `webhooks.events.get(webhook_id, event_id)` | `GET /webhooks/{id}/events/{event_id}` |
| `webhooks.events.attempts(webhook_id, event_id, limit=None, after=None, before=None)` | `GET /webhooks/{id}/events/{event_id}/attempts` |
| `webhooks.events.replay(webhook_id, event_id)` | `POST /webhooks/{id}/events/{event_id}/replay` |
| `suppressions.create({"email": …})` | `POST /suppressions` |
| `suppressions.get(id_or_email)` | `GET /suppressions/{id or email}` |
| `suppressions.list(limit=None, after=None, before=None)` | `GET /suppressions` |
| `suppressions.remove(id_or_email)` | `DELETE /suppressions/{id or email}` |
| `logs.list(limit=None, after=None, before=None)` | `GET /logs` |
| `logs.get(log_id)` | `GET /logs/{id}` |
| `mailboxes.list(limit=None, after=None, before=None, domain=None)` | `GET /mailboxes` |
| `mailboxes.get(email)` | `GET /mailboxes/{email}` |
| `mailboxes.create({"email": …, "name": …, "quota_mb": …, "org_unit_id": …})` | `POST /mailboxes` |
| `mailboxes.update(email, {"name", "quota_mb", "active", "force_password_change"})` | `PATCH /mailboxes/{email}` |
| `mailboxes.remove(email)` | `DELETE /mailboxes/{email}` |
| `mailboxes.reset_password(email)` | `POST /mailboxes/{email}/password` |
| `mailboxes.aliases.list(email)` | `GET /mailboxes/{email}/aliases` |
| `mailboxes.aliases.create(email, {"address": …})` | `POST /mailboxes/{email}/aliases` |
| `mailboxes.aliases.remove(email, address)` | `DELETE /mailboxes/{email}/aliases/{address}` |
| `groups.list(limit=None, after=None, before=None, domain=None)` | `GET /groups` |
| `groups.get(address)` | `GET /groups/{address}` |
| `groups.create({"address": …, "name": …, "members": […]})` | `POST /groups` |
| `groups.update(address, {"name": …, "members": […]})` | `PATCH /groups/{address}` |
| `groups.remove(address)` | `DELETE /groups/{address}` |

### Pagination

List methods take `limit` (1 to 100, default 20) and `after` or `before`, an id from the previous page (not both). They
return `{"object": "list", "has_more": bool, "data": [...]}`. Mailboxes and groups are listed by address, so their
cursors are addresses (the `email` or `address` of the last item), and they also take `domain`.

```python
after = None
while True:
    page = client.emails.list(limit=100, after=after)
    for email in page["data"]:
        print(email["id"], email["last_event"])
    if not page["has_more"]:
        break
    after = page["data"][-1]["id"]
```

## Webhook verification

Each webhook request carries the headers `webhook-id`, `webhook-timestamp` and `webhook-signature`. `verify_webhook`
checks the signature with the webhook's signing secret, refuses timestamps more than 5 minutes from now, and returns the
parsed event. A request that does not verify raises `WebhookVerificationError`; answer it with 400.

```python
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
from tsmart import WebhookVerificationError, verify_webhook

SECRET = os.environ["TSMART_WEBHOOK_SECRET"]


class Receiver(BaseHTTPRequestHandler):
    def do_POST(self):
        body = self.rfile.read(int(self.headers.get("Content-Length", 0)))  # the raw body
        try:
            event = verify_webhook(body, self.headers, SECRET)
        except WebhookVerificationError:
            self.send_response(400)
            self.end_headers()
            return
        if event["type"] == "email.bounced":
            print("Bounced:", event["data"]["to"], event["data"]["bounce"]["message"])
        self.send_response(204)
        self.end_headers()


HTTPServer(("", 8000), Receiver).serve_forever()
```

```python
verify_webhook(payload, headers, secret, tolerance=300, now=None)
```

- `payload`: the raw request body as `str` or `bytes`. The signature covers the body exactly as sent; parsing the JSON
  and serializing it again changes it.
- `headers`: any mapping of the request headers (names in any case), or a list of `(name, value)` pairs.
- `secret`: the webhook's signing secret, `whsec_…`.
- `tolerance`: accepted distance of `webhook-timestamp` from now, in seconds. `now` replaces the clock (for tests).

A missing or malformed secret, a payload that is not `str` or `bytes`, or no headers raise `ValueError` or `TypeError`:
those are configuration errors, not bad requests. `webhook-id` stays the same on every retry and replay of an event, so
you can use it to skip duplicates.

## Errors

| Status | Name | Meaning |
| --- | --- | --- |
| 400 | `invalid_idempotency_key` | The idempotency key is not 1 to 256 characters. |
| 401 | `missing_api_key` | No API key was sent. |
| 401 | `restricted_api_key` | A sending-access key called an endpoint that needs full access. |
| 403 | `invalid_api_key` | The key is malformed, unknown or revoked, or the company is suspended. |
| 403 | `invalid_permission` | The person who created the key may not do this in the console. |
| 403 | `invalid_from_address` | The from domain is not one of the company's verified domains, or not the key's domain. |
| 403 | `domain_not_verified` | The SPF and DKIM records of the from domain are not verified. |
| 403 | `service_disabled` | tsmartSend is turned off for the company. |
| 403 | `ip_not_allowed` | The company only allows API calls from certain addresses. |
| 403 | `account_inactive` | The company's subscription is not active. |
| 404 | `not_found` | The object or the endpoint does not exist. |
| 405 | `method_not_allowed` | The endpoint does not support this HTTP method. |
| 409 | `invalid_idempotent_request` | The idempotency key was used in the last 24 hours with a different body. |
| 409 | `concurrent_idempotent_requests` | A request with the same idempotency key is still running. |
| 409 | `resource_in_use` | The domain still has mailboxes. |
| 409 | `already_exists` | The address is already used by a mailbox, a group or an alternate email. |
| 413 | `payload_too_large` | The body is over the limit: 1 MB outside sending; a sending request over 30 MB gets a 413 without a JSON body, raised as `application_error`. |
| 422 | `validation_error` | The request is not valid; the message says why. Also used with 403 and 429 for some domain and webhook limits, and with 403 for the plan's user limit. |
| 422 | `missing_required_field` | A required field is missing. |
| 422 | `invalid_attachment` | An attachment is not valid. |
| 429 | `rate_limit_exceeded` | Too many requests per second, an hourly sending limit, or another limit; the message says which. |
| 429 | `monthly_quota_exceeded` | The plan's monthly sending quota is used up. |
| 502 | `mail_server_error` | The mail server refused a mailbox, alternate email or group change; the message carries its error codes. Read the object again before retrying. |
| 500 or other | `application_error` | An unexpected error. A response that is not JSON is raised under this name too, and `status_code` keeps its real HTTP status (for example 502 from a gateway). |
| none | `network_error` | No response: the request timed out or the API could not be reached. `status_code` is `None`. |

## Retries and timeouts

- An HTTP 429 means the request was not carried out, so the client retries it up to `max_retries` times (default 2). It
  waits for the `retry-after` header, else `ratelimit-reset`, else 1 second, and never longer than 60 seconds.
  `monthly_quota_exceeded` and `validation_error` (a 429 for too many domains waiting for verification) are not
  retried. Every `rate_limit_exceeded` is, including limits that do not clear within seconds, such as the hourly
  sending limit: the client then raises the same error after the retries (about 2 seconds later with the defaults).
- A timeout or network error is retried (after 1 second) for GET requests only. A POST, PATCH or DELETE is never
  repeated, because it may have arrived; the client raises `TsmartError` with `name="network_error"`.
- Other errors, including 5xx responses, are raised at once. Redirects are never followed, so the API key is never sent
  to another address.

After a `network_error` on `emails.send` it is unknown whether the email went out. Send with an `idempotency_key` and
repeat the call with the same key and body: if the first request arrived, the API returns its response and sends
nothing again.

## Type hints

The package is typed (it ships a `py.typed` marker). Responses are `Dict[str, Any]`; request bodies are any mapping.

## License

MIT. See [LICENSE](LICENSE).
