Metadata-Version: 2.4
Name: thousandmails
Version: 1.0.1
Summary: Official Python SDK for the ThousandMails client API — transactional email, attachments, statistics, delivery logs and a live event stream.
Author: Hariraghav.S
License: ThousandMails Python SDK — Licence
        
        Copyright © 2026 Techinorm Solutions Pvt Ltd. All rights reserved.
        
        This software is licensed, not sold. By installing, copying or using it you
        accept the terms below. If you do not accept them, do not install or use it.
        
        1. Grant of licence
        
           Techinorm Solutions Pvt Ltd grants you a non-exclusive, non-transferable,
           revocable, worldwide licence to:
        
           a. install and use this software, in unmodified form, within your own
              applications, for the sole purpose of accessing the ThousandMails service;
              and
        
           b. redistribute it, in unmodified form, only as an installed dependency of
              those applications.
        
        2. Restrictions
        
           You may not:
        
           a. modify, adapt, translate or otherwise alter the source code, or create
              derivative works based on it, in whole or in part;
        
           b. reverse engineer, decompile or disassemble the software, except to the
              extent applicable law expressly permits despite this restriction;
        
           c. remove, obscure or alter any copyright, trademark or other proprietary
              notice contained in the software;
        
           d. distribute, publish, sublicense, sell, rent, lease or lend the software
              other than as permitted by section 1(b), or publish it — in whole or in
              part, under this or any other name — as a separate package or as part of
              a competing software development kit; or
        
           e. use the software for any purpose other than accessing the ThousandMails
              service.
        
        3. Automated build steps
        
           Bundling, minification, transpilation, compression, tree-shaking and similar
           automated build steps that do not change the behaviour of the software are
           permitted, and are not "modification" for the purposes of section 2(a).
        
        4. Ownership
        
           All right, title and interest in the software, including all intellectual
           property rights in it, remain with Techinorm Solutions Pvt Ltd. No rights are
           granted to you except those expressly set out in this licence.
        
        5. No warranty
        
           THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
           IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
           FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
        
        6. Limitation of liability
        
           IN NO EVENT SHALL TECHINORM SOLUTIONS PVT LTD BE LIABLE FOR ANY CLAIM,
           DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
           OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
           USE OF OR OTHER DEALINGS IN THE SOFTWARE.
        
        7. Termination
        
           This licence terminates automatically, without notice, if you breach any of
           its terms. On termination you must stop using the software and destroy all
           copies in your possession.
        
Keywords: thousandmails,mailer,email,smtp,transactional-email,sdk
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Communications :: Email
Classifier: Typing :: Typed
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

# thousandmails

Python SDK for ThousandMails — transactional email, attachments, statistics,
delivery logs and a live event stream, behind one client object.

- One thing to configure: your API key. No host, no setup step, no init call
- Zero dependencies — Python 3.8+, standard library only
- Type hints throughout, shipped with `py.typed`
- Local validation, so a typo fails in microseconds instead of a round trip
- Automatic idempotency keys on single sends, so a retry can't double-send
- Typed errors, retry with backoff, and a reconnecting event stream

```bash
pip install thousandmails
```

## Quick start

Your API key is the only thing to configure. Generate one on the in-app **API
keys** page, and you're sending:

```python
from thousandmails import ThousandMails

thousandmails = ThousandMails(api_key="tm_live_...")

result = thousandmails.send_mail({
    "senderemail": "noreply@yourdomain.com",
    "to": "customer@example.com",
    "subject": "Your receipt",
    "html": "<p>Thanks for your order.</p>",
})

print(result["status"], result["messageId"])
```

Set `THOUSANDMAILS_API_KEY` in your environment and even that argument goes away:

```python
thousandmails = ThousandMails()
```

Everything else — the host, timeouts, retries, idempotency keys, validation — is
configured for you with working defaults. There is no setup step, no
initialisation call, and nothing to install alongside it.

Messages and query filters are plain dicts whose keys are exactly what the API
documents, so anything you read in the API reference works here unchanged.
Per-call options (`timeout`, `idempotency_key`) are keyword arguments.

> **Timeouts are in milliseconds**, not seconds — `timeout=30000` is thirty
> seconds. The Node, PHP and Python SDKs take the same number so a service
> running more than one of them configures them the same way.

## Methods

Every method is reachable two ways — through its resource and as a flat alias on
the client. `thousandmails.emails.send()` and `thousandmails.send_mail()` are the same
bound method; use whichever reads better.

### Sending — `thousandmails.emails`

| Method                                    | Flat alias                       | Does                                                                          |
| ----------------------------------------- | -------------------------------- | ----------------------------------------------------------------------------- |
| `send(message, **options)`                | `send_mail()`                    | Sends one email. Routes to the multipart path when the message carries files. |
| `send_batch(messages, **options)`         | `send_batch()`                   | Up to 100 emails in one request, each with its own outcome.                   |
| `send_with_attachments(message, ...)`     | `send_mail_with_attachments()`   | One email with 1–5 files.                                                     |
| `send_batch_with_attachments(messages)`   | `send_batch_with_attachments()`  | Up to 20 emails, each carrying its own files.                                 |
| `get(message_id)`                         | `get_message()`                  | Looks up a send by its record id or its SMTP message id.                      |

### Statistics — `thousandmails.stats`

| Method                  | Flat alias                | Does                                                          |
| ----------------------- | ------------------------- | ------------------------------------------------------------- |
| `summary(params)`       | `get_stats_summary()`     | Totals for a window, the preceding window, and derived rates. |
| `timeseries(params)`    | `get_stats_timeseries()`  | The same metrics bucketed by day, week or month.              |
| `by_sender(params)`     | `get_stats_by_sender()`   | Totals grouped by sender address.                             |
| `by_tag(params)`        | `get_stats_by_tag()`      | Send outcomes grouped by tag.                                 |

### Realtime — `thousandmails.realtime`

| Method                | Flat alias                    | Does                                                     |
| --------------------- | ----------------------------- | -------------------------------------------------------- |
| `stats(params)`       | `get_realtime_stats()`        | Every metric summed over the last N minutes (1–120).     |
| `per_minute(params)`  | `get_realtime_per_minute()`   | The last N per-minute buckets, oldest first.             |
| `per_second(params)`  | `get_realtime_per_second()`   | Per-second buckets over the last N seconds, zero-filled. |
| `activity(params)`    | `get_realtime_activity()`     | The most recent events, newest first (1–100).            |
| `stream(**options)`   | `stream_events()`             | A live event stream. Returns an `EventStream`.           |

### Logs — `thousandmails.logs`

| Method                             | Flat alias                | Does                                                      |
| ---------------------------------- | ------------------------- | --------------------------------------------------------- |
| `list(query)`                      | `get_logs()`              | One page of events, newest first.                         |
| `iterate(query)`                   | `iterate_logs()`          | Generator over every matching event, paged for you.       |
| `get(event_id)`                    | `get_log()`               | One event by id.                                          |
| `export(query)`                    | `export_logs()`           | The filtered log as CSV text.                             |
| `export_to_file(path, query)`      | `export_logs_to_file()`   | Writes that CSV to disk and returns the row count.        |

Every method takes a final `timeout` keyword (milliseconds, overriding the
client's; `0` to disable). Single sends also accept `idempotency_key`.

## Sending

### Raw and templated

```python
# Raw
thousandmails.send_mail({
    "senderemail": "noreply@yourdomain.com",
    "to": ["ada@example.com", {"name": "Bob", "address": "bob@example.com"}],
    "cc": "manager@example.com",
    "bcc": "archive@example.com",
    "subject": "Welcome, {{name}}",
    "html": "<p>Hello {{name}}</p>",
    "templaterequiredfields": {"name": "Ada"},
})

# From a saved template
thousandmails.send_mail({
    "senderemail": "noreply@yourdomain.com",
    "to": "ada@example.com",
    "templateid": "tpl_abc123",
    "templaterequiredfields": {"name": "Ada", "order": "4417"},
})
```

Send _either_ `templateid` _or_ `subject`/`text`/`html` — never both.
`{{placeholders}}` work in raw bodies as well as templates, and every one of them
needs a value.

Recipients accept a string, a comma-separated string, a list, or
`{"name": ..., "address": ...}` mappings. Everyone on `to`/`cc` sees each other —
use `send_batch` for individually addressed mail. `bcc` is stripped from the
message and travels in the envelope only, hidden from every other recipient.

`senderemail` also answers to `from`, `templateid` to `templateId`, and
`templaterequiredfields` to `templateRequiredFields`.

### Attachments

`send_mail` routes to the multipart path automatically when the message has
`attachments`:

```python
thousandmails.send_mail({
    "senderemail": "billing@yourdomain.com",
    "to": "customer@example.com",
    "subject": "Invoice 4417",
    "text": "Attached.",
    "attachments": [
        "./invoices/4417.pdf",                                  # a path
        {"path": "/tmp/tmp-2f8a.pdf", "filename": "terms.pdf"},  # a path, renamed
        {"filename": "lines.csv", "content": "sku,qty\nA1,2"},   # text
        {"filename": "logo.png", "content": png_bytes},          # bytes
    ],
})
```

Limits, checked locally before anything uploads: 1–5 files per message, 5 MB per
file, 20 MB per request, unique filenames, and only
`csv, xls, xlsx, doc, docx, pdf, jpg, jpeg, png`. Files are also inspected on
arrival — a renamed `.exe` is rejected however it is labelled.

### Batches

```python
batch = thousandmails.send_batch([
    {"senderemail": "noreply@yourdomain.com", "to": "a@example.com",
     "subject": "Hi", "text": "One"},
    {"senderemail": "noreply@yourdomain.com", "to": "b@example.com",
     "subject": "Hi", "text": "Two"},
])

for entry in batch["results"]:
    if entry["outcome"] == "error":
        print(entry["index"], entry["error"]["message"])
```

A batch **always returns** — each entry succeeds or fails on its own, so check
`outcome` per entry rather than relying on the call not raising. Up to 100
messages, or 20 for the attachment variant, where every message must carry at
least one file.

## Idempotency

Single sends carry an idempotency key by default, generated per call. If the SDK
retries after a timeout or a server error, the key is recognised and the original
result comes back instead of a second send.

```python
# Your own key, stable across process restarts — de-duplicates at your level too
thousandmails.send_mail(message, idempotency_key=f"receipt-{order_id}")

# Opt out
thousandmails.send_mail(message, idempotency_key=False)
```

Keys do not apply to batches, so batch sends are never retried automatically.

## Retries

`max_retries` (default 2) applies with exponential backoff and full jitter:

| Situation                                 | Retried?                                    |
| ----------------------------------------- | ------------------------------------------- |
| Rate limited                              | yes — the request was refused untouched     |
| Upload capacity reached                   | yes — same reason, honours `Retry-After`    |
| Server error, timeout, dropped connection | only with an idempotency key                |
| Batch sends                               | never                                       |
| Any other rejection                       | never                                       |

A `Retry-After` longer than the SDK is willing to wait (8s) is not slept
through. The window would still be shut when the retry landed, so the call fails
immediately instead and `error.retry_after` carries the server's real figure for
you to back off against.

`timeout` applies **per attempt**, not per call: a retried request gets a fresh
window, so a call can take up to `(max_retries + 1) × timeout` in the worst case.

`thousandmails.rate_limit` holds `{"limit", "remaining", "reset"}` from the last
response so you can pace yourself before being throttled. The budget is per API
key — 1000 requests per 15 minutes.

## Reading

```python
summary = thousandmails.get_stats_summary({"from": "2026-07-01", "to": "2026-07-31"})
print(summary["totals"]["delivered"], summary["rates"]["openRate"])

thousandmails.get_stats_timeseries({"from": "2026-07-01", "interval": "week"})
thousandmails.get_stats_by_sender()
thousandmails.get_realtime_stats({"minutes": 15})

# One page, or every event
page = thousandmails.get_logs({"event": "bounced", "pageSize": 100})
for event in thousandmails.iterate_logs({"event": "bounced"}):
    print(event["recipient"], event["dsn"])

thousandmails.export_logs_to_file("./bounces.csv", {"event": "bounced"})
```

Dates are `YYYY-MM-DD` (UTC), a `datetime.date`, or a `datetime.datetime`. An
unparsable date is silently ignored upstream and would quietly widen your window,
so the SDK rejects it instead.

### Live event stream

```python
stream = thousandmails.stream_events()

stream.on("email", lambda event: print(event["type"], event["recipient"]))
stream.on("error", lambda error: print("stream:", error))
stream.listen()          # blocks, dispatching to the handlers above

# …or iterate
for event in thousandmails.stream_events().events():
    if event["type"] == "bounced":
        handle_bounce(event)
        break            # breaking out closes the connection

# …or scope it
with thousandmails.stream_events() as stream:
    for event in stream.events():
        ...
```

`EventStream` dispatches `ready`, `email`, `heartbeat`, `frame`, `open`, `close`,
`error` and `end`. It reconnects with backoff after a dropped connection and
gives up on an authentication failure, which will not resolve itself. Pass
`reconnect=False` to disable.

Nothing is queued: an event goes to exactly one consumer and is then dropped, so
a stream held open for days does not grow. Always `close()` it (or `break` out of
the loop) — the connection is held open by design and blocks the process
otherwise.

> **A reconnect does not replay what it missed.** The SDK sends `Last-Event-ID`
> when the server labels frames with an `id:`, but the stream endpoint does not
> label them today, so a reconnect resumes from the live edge and events that
> occurred during the gap are not delivered. Where you cannot afford to miss
> one, treat the stream as a low-latency notification and reconcile against
> `client.logs` — the log is the record of what happened, the stream is not.

## Errors

```python
from thousandmails import (
    InvalidInputError, ValidationError, PermissionError, RateLimitError,
)

try:
    thousandmails.send_mail(message)
except InvalidInputError as error:
    ...                                  # rejected locally — nothing was sent
except ValidationError as error:
    print(error.missing_fields)
except PermissionError as error:
    print("suppressed:", error.suppressed)
except RateLimitError as error:
    print("retry after", error.retry_after, "seconds")
```

Every error extends `ThousandMailsError`. Anything that reached the service and came
back a failure also carries `status`, `body`, `headers`, `method` and `url`.

| Class                              | `status` | Meaning                                        |
| ---------------------------------- | -------- | ---------------------------------------------- |
| `InvalidInputError`                | —        | Rejected locally, before anything was sent     |
| `BadRequestError`                  | 400      | Unknown template, sender, message or event id  |
| `AuthenticationError`              | 401      | Missing, malformed or unknown key              |
| `APIError`                         | 402      | Your plan does not cover it — see `body`       |
| `PermissionError`                  | 403      | Inactive key, IP-pinned sender, all suppressed |
| `PayloadTooLargeError`             | 413      | An attachment or field went over the limit     |
| `UnsupportedMediaTypeError`        | 415      | An upload was not encoded as multipart         |
| `ValidationError`                  | 422      | Understood and rejected                        |
| `RateLimitError`                   | 429      | Throttled — see `retry_after`                  |
| `ServiceUnavailableError`          | 503      | Upload capacity reached — safe to retry        |
| `ServerError`                      | 5xx      | Service or transport failure                   |
| `TimeoutError` / `ConnectionError` | —        | The request never completed                    |
| `ConfigError`                      | —        | Bad or missing client configuration            |

`ConnectionError`, `TimeoutError` and `PermissionError` share their names with
Python builtins. Import them from `thousandmails` explicitly, or reach them as
`thousandmails.TimeoutError`, so it stays obvious which one a handler catches.

### Plan limits

Your key inherits its account's plan, so the same subscription that governs the
dashboard governs this SDK. Two refusals come from there. Both are `402`, both
arrive as `APIError` with the response body intact, and neither is retried —
the answer will not change until the plan does or the billing period rolls over.

- **`Not included in your plan`** — the call needs an entitlement the plan does
  not carry. The stats, realtime and log methods all need `apiAccess` at
  `full`; a `limited` plan may send and read back its own send records, and
  gets this on everything else. The body names the `feature`, what you hold
  (`current`) and what is `required`.
- **`Monthly send limit reached`** — the period's send allowance is spent. The
  body carries `limit`, `used`, `requested`, `remaining` and `resetsAt`. A batch
  is weighed whole, so an over-cap batch sends nothing rather than part of
  itself.

A `503` carrying `Entitlement check unavailable` is a different thing: the
service could not read your plan, not a statement about it. That one is
transient and the retry policy handles it for you.

Full detail in the
[client API reference](../../../backend/client/docs/api-reference.md#plan-entitlements).

## Bring your own HTTP client

The SDK talks to the network through a small `Transport` interface and ships a
`urllib` implementation, which is what keeps the dependency list empty. `urllib`
opens a fresh connection per request; if that matters at your send volume,
supply a transport backed by a pooled client:

```python
from thousandmails import ThousandMails, Transport

class MyTransport(Transport):
    def send(self, request): ...
    def open(self, request): ...

thousandmails = ThousandMails(api_key=..., transport=MyTransport())
```

## License

Proprietary. Copyright © 2026 Techinorm Solutions Pvt Ltd. All rights reserved.

You may install and use this SDK, **unmodified**, inside your own applications
to access the ThousandMails service, and redistribute it unmodified as a dependency
of those applications.

You may not modify or adapt the source, create derivative works from it, reverse
engineer it, strip its notices, or republish it as a separate package. Automated
build steps that don't change behaviour — bundling, byte-compilation, packaging —
are fine.

The software is provided "as is", without warranty of any kind. See
[LICENSE](LICENSE) for the full terms.
