Metadata-Version: 2.5
Name: mailcheer
Version: 0.2.0
Summary: The official Python SDK for the Mailcheer email API: transactional emails, subscribers, campaigns and signed webhooks.
Project-URL: Homepage, https://mailcheer.com
Project-URL: Documentation, https://mailcheer.com/docs/api
Author-email: Mailcheer <contact@mailcheer.com>
License-Expression: MIT
License-File: LICENSE
Keywords: amazon ses,email,email api,mailcheer,newsletter,sdk,transactional email,webhooks
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 :: Only
Classifier: Topic :: Communications :: Email
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: httpx<1,>=0.25
Description-Content-Type: text/markdown

# Mailcheer for Python

The official Python SDK for [Mailcheer](https://mailcheer.com): transactional emails, subscribers, campaigns and signed webhooks — blocking or asyncio, fully typed.

- A blocking client and an `asyncio` client, with the same methods.
- Typed responses (`TypedDict`) and typed errors with a stable `code`.
- Safe automatic retries: `Retry-After` respected, and a send is only retried with an idempotency key.
- Your monthly quota read from every response.
- Webhook signatures verified in one line.

```bash
pip install mailcheer
```

## Send an email

Create a key under **Settings → API** in your workspace, then:

```python
from mailcheer import Mailcheer

mailcheer = Mailcheer("mch_live_…")  # or set MAILCHEER_API_KEY and call Mailcheer()

email = mailcheer.emails.send({
    "from": "Acme <billing@acme.com>",
    "to": "jane@example.com",
    "subject": "Your September invoice",
    "html": "<p>Here it is.</p>",
    "text": "Here it is.",
})
print(email["id"])  # accepted (202), on its way
```

`from` must be on a domain verified in your workspace. `mailcheer.me()` lists your verified domains and senders.

Responses are plain dictionaries, shaped exactly like the API's JSON and typed for your editor.

## Errors

A refusal raises a `MailcheerError`. Read `error.code`: it is stable and never translated.

```python
from mailcheer import MailcheerError, QuotaExceededError

try:
    mailcheer.emails.send(email)
except QuotaExceededError as error:
    print("Quota reached; resets", error.details["resets_at"])
except MailcheerError as error:
    print(error.code, error.message, error.status_code, error.details)
```

| Class | When |
| --- | --- |
| `AuthenticationError` | 401 — missing, unknown or revoked key |
| `QuotaExceededError` | 402 — `quota_exceeded`, `free_plan_domain_used` |
| `PermissionDeniedError` | 403 — missing permission, sending blocked |
| `NotFoundError` | 404 |
| `ConflictError` | 409 — idempotency key reused with another body… |
| `ValidationError` | 422 — faulty field, unverified domain, suppressed recipient |
| `SendingPausedError` | 423 — workspace under review: send the same call later |
| `RateLimitError` | 429 — `retry_after` tells how long to wait |
| `ServerError` | 5xx |
| `MailcheerConnectionError` | no answer: `network_error` or `timeout` |

Messages are in English by default; `Mailcheer(language="fr")` asks for French.

## Retries and idempotency

Pass an `idempotency_key` — an invoice number, an order id — and a send can be retried as often as needed: it goes out once. Mailcheer replays the first response for 24 hours.

```python
mailcheer.emails.send(email, idempotency_key=f"invoice-{invoice.id}")
```

The SDK retries on its own (2 retries by default) after a network failure, a timeout, a `429` or a `5xx`, waiting for `Retry-After` when the API gives it. It only retries a call that changes something when that call carries an idempotency key — or when the API refused it before reading it (`rate_limit_exceeded`). A retry never sends an email twice.

## Your quota, on every response

```python
mailcheer.emails.send(email)
mailcheer.last_quota     # {"limit": 3000, "used": 2531, "remaining": 469, "reset_at": "2026-10-01T00:00:00.000Z"}
mailcheer.last_response  # status, quota, rate_limit, idempotent_replay, retries
mailcheer.quota()        # read it now, from GET /api/v1/me
```

`limit` and `remaining` are `None` on an unlimited plan.

A key with its own monthly limit (Settings → API) also reports it in `mailcheer.last_key_quota` (`limit`, `used`, `remaining`); beyond it a send raises `QuotaExceededError` with `code == "key_quota_exceeded"`.

With a test key (`mch_test_…`), every call is checked as in production but nothing is sent and the quota is not touched: `mailcheer.last_response.mode` is `"test"`, and `mailcheer.me()["key"]["mode"]` says so.

## Subscribers and campaigns

```python
mailcheer.subscribers.upsert({"email": "jane@example.com", "firstName": "Jane", "tags": ["customer"]})

for subscriber in mailcheer.subscribers.list_all(status="subscribed"):
    print(subscriber["email"])

batch = mailcheer.subscribers.batch(contacts, idempotency_key="import-2026-09")  # up to 500
changed = mailcheer.subscribers.list(updated_since=last_sync)
mailcheer.subscribers.update_tags("jane@example.com", add=["vip"], remove=["lead"])
mailcheer.tags.update_subscribers("vip", add=["joe@example.com"])
mailcheer.subscribers.erase("jane@example.com")  # GDPR erasure, irreversible

draft = mailcheer.campaigns.create({"name": "October newsletter", "subject": "What's new", "text": "# Hello\n\nThree new things…"})
preview = mailcheer.campaigns.send(draft["id"], {"dry_run": True})  # checks everything, sends nothing
mailcheer.campaigns.send(draft["id"])                                # irreversible
stats = mailcheer.campaigns.stats(draft["id"])
```

Also: `subscribers.get()`, `subscribers.unsubscribe()`, `suppression.list()`, `suppression.add()`, `campaigns.update()` (a draft), `campaigns.list_recipients()`, `campaigns.preview_audience(to=[…])`, `tags.list()`, `tags.rename()`, `tags.delete()`, and the `webhooks` resource.

## asyncio

```python
from mailcheer import AsyncMailcheer

async with AsyncMailcheer() as mailcheer:
    email = await mailcheer.emails.send({...}, idempotency_key="invoice-42")
    async for subscriber in mailcheer.subscribers.list_all():
        ...
```

## Webhooks

Verify the signature on the **raw** body, before parsing:

```python
# Flask
from mailcheer import WebhookSignatureError, construct_webhook_event, is_test_event

@app.post("/mailcheer")
def mailcheer_webhook():
    try:
        event = construct_webhook_event(
            os.environ["MAILCHEER_WEBHOOK_SECRET"],
            request.headers.get("Mailcheer-Signature"),
            request.get_data(),
        )
    except WebhookSignatureError:
        return "Invalid signature", 400
    if is_test_event(event):
        return "", 200
    if event["type"] == "email.bounced":
        mark_bounced(event["data"]["email"])
    return "", 200
```

With Django, pass `request.body`; with FastAPI, `await request.body()`. A call signed more than five minutes ago is refused. `event["id"]` is the same on every attempt: use it to ignore duplicates. An event simulated for a test key carries `event.get("test") is True`, with the fields of a real event — `is_test_event()` only recognises the ping of the “Send a test” button.

## Options

```python
Mailcheer(
    api_key,
    base_url="https://mailcheer.com",  # or MAILCHEER_BASE_URL
    max_retries=2,
    timeout=60.0,          # per attempt, in seconds
    max_retry_after=60.0,  # the longest Retry-After waited for on its own
    language="en",         # "fr" for French messages
    headers={},            # added to every request
    http_client=None,      # your own httpx.Client (proxy, transport…)
)
```

For a route not wrapped here, `mailcheer.request("GET", "/api/v1/…", query=…, body=…)` gives the same retries and errors.

Python 3.9 or later.

## Links

- [API reference](https://mailcheer.com/docs/api)
- [OpenAPI file](https://mailcheer.com/openapi.json)
- [Node.js SDK](https://www.npmjs.com/package/mailcheer)

## License

MIT
