Metadata-Version: 2.5
Name: naijacloud-email
Version: 0.3.1
Summary: Official Python SDK for Naijamail, Naija Cloud's transactional email API.
Project-URL: Homepage, https://www.naijacloud.com
Project-URL: Documentation, https://naijacloud.com/docs/api/email
Project-URL: Source, https://github.com/naijacloud/nc-email-python
Project-URL: Changelog, https://github.com/naijacloud/nc-email-python/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/naijacloud/nc-email-python/issues
Author-email: Naija Cloud <support@naijacloud.com>
Maintainer-email: Naija Cloud <support@naijacloud.com>
License: MIT License
        
        Copyright (c) 2026 NaijaCloud
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        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. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS 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 OR OTHER DEALINGS IN THE
        SOFTWARE.
License-File: LICENSE
Keywords: api,email,naija cloud,naijamail,smtp,transactional email
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
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: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: Python :: Implementation :: PyPy
Classifier: Topic :: Communications :: Email
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.9
Provides-Extra: dev
Requires-Dist: mypy>=1.5; extra == 'dev'
Requires-Dist: pytest>=7; extra == 'dev'
Requires-Dist: ruff>=0.4; extra == 'dev'
Description-Content-Type: text/markdown

<p align="center">
  <a href="https://www.naijacloud.com">
    <img alt="Naijamail — Python SDK" src="https://raw.githubusercontent.com/naijacloud/nc-email-python/main/.github/assets/banner.png" width="100%">
  </a>
</p>

<p align="center">
  <a href="https://pypi.org/project/naijacloud-email/"><img alt="pypi" src="https://img.shields.io/badge/pypi-naijacloud--email-008751?style=flat-square&labelColor=0A0E0C"></a>
  <img alt="python" src="https://img.shields.io/badge/python-%3E%3D_3.9-4B8BBE?style=flat-square&labelColor=0A0E0C">
  <img alt="dependencies" src="https://img.shields.io/badge/dependencies-0-46C98A?style=flat-square&labelColor=0A0E0C">
  <a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-8A988F?style=flat-square&labelColor=0A0E0C"></a>
</p>

<p align="center">
  <a href="#quickstart">Quickstart</a> ·
  <a href="#the-api">The API</a> ·
  <a href="#options">Options</a> ·
  <a href="#errors">Errors</a> ·
  <a href="#retries">Retries</a> ·
  <a href="#webhooks">Webhooks</a> ·
  <a href="#security">Security</a>
</p>

# Naijamail for Python

The official Python SDK for [Naijamail](https://www.naijacloud.com), Naija
Cloud's transactional email API.

Zero runtime dependencies. Python 3.9 and newer.

```bash
pip install naijacloud-email
```

## Quickstart

```python
from nc_email import Naijamail

nm = Naijamail()  # reads NAIJAMAIL_API_KEY

sent = nm.emails.send(
    from_="Acme <hello@acme.com>",
    to="customer@example.com",
    subject="Your receipt",
    html="<p>Thanks for your order.</p>",
)
print(sent.id, sent.status)          # 5b1e… queued

email = nm.emails.get(sent.id)
print(email.status, email.delivered_at)
```

`from_` carries a trailing underscore because `from` is a reserved word. If you
are porting from another SDK you can pass the whole thing as a dict instead, and
spell it `from`:

```python
nm.emails.send({
    "from": "Acme <hello@acme.com>",
    "to": ["a@example.com", "b@example.com"],
    "reply_to": "support@acme.com",
    "subject": "Invoice #1024",
    "html": "<p>Attached.</p>",
    "attachments": [
        {"filename": "invoice-1024.pdf", "content": pdf_bytes, "content_type": "application/pdf"},
    ],
    "tags": {"campaign": "invoices"},
})
```

## The API

This release wraps two endpoints, send and retrieve. The API also has batch send,
a message list, limits, domains and suppressions
([API docs](https://naijacloud.com/docs/api/email)); they are not wrapped yet.

| | |
| --- | --- |
| `nm.emails.send(...)` | `POST /v1/emails` — returns `SendEmailResponse(id, status, rejected, sandbox)` |
| `nm.emails.get(id)` | `GET /v1/emails/{id}` — returns `Email` |

Call the other routes over plain HTTPS with the same key until they are wrapped.

### Sending

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `from_` (or `from`) | `str` | yes | `"Name <a@b.com>"` or `"a@b.com"`. The domain must be verified for your team. |
| `to` | `str` \| `list[str]` | yes | At least one address. |
| `cc`, `bcc`, `reply_to` | `str` \| `list[str]` | no | `replyTo` is accepted as well; the SDK always sends `reply_to`. |
| `subject` | `str` | yes | The API refuses a missing or blank subject with a 400 `subject is required`. |
| `html`, `text` | `str` | one of them | At least one is required (400 `provide html, text, or both`); send both for the best inbox placement. |
| `headers` | `dict[str, str]` | no | At most 25. `From`, `To`, `Cc`, `Bcc`, `Subject`, `DKIM-Signature` and `Received` are refused, case-insensitively and with surrounding whitespace ignored (`" From"` is refused too). |
| `attachments` | `list[dict]` | no | `{filename, content, content_type?, content_id?}` |
| `tags` | `dict[str, str]` | no | At most 10; keys up to 64 chars, values up to 256. |
| `idempotency_key` | `str` | no | One is generated for you if you leave it out or pass `""`. At most 255 bytes of UTF-8. Sent as the `Idempotency-Key` header only. |

`SendEmailResponse.rejected` is always a list, empty when nobody was rejected —
the API omits the field entirely in that case, and you should not have to branch
on absence. A non-empty `rejected` is not an error: those recipients are on the
suppression list and the rest of the message still went out.

`Email.from_` carries the same trailing underscore, for the same reason.

`Email.created_at` and `Email.delivered_at` are the ISO 8601 strings the API
sends (`"2026-08-29T10:00:00.000Z"`), not `datetime` objects — parse them with
`datetime.fromisoformat(value.replace("Z", "+00:00"))` if you need one. The other
SDKs surface their language's own date type; that difference is deliberate.

Statuses arrive as plain lowercase strings: `queued`, `sent`, `delivered`,
`bounced`, `deferred`, `complained`, `rejected`, `failed`. Compare them against
`MessageStatus.DELIVERED` and friends. They are constants rather than an enum on
purpose — a strict enum would raise the day the platform adds a status, breaking
every SDK older than that day. Use `MessageStatus.is_known(status)` if you need
to notice a new one.

### Attachments

Pass raw `bytes`. The SDK base64-encodes them:

```python
with open("invoice.pdf", "rb") as handle:
    content = handle.read()

nm.emails.send(
    from_="Acme <hello@acme.com>",
    to="customer@example.com",
    subject="Invoice",
    text="Attached.",
    attachments=[{"filename": "invoice.pdf", "content": content}],
)
```

`bytes`, `bytearray` and `memoryview` are raw content. A `str` is accepted only
as already-encoded base64, and is validated strictly rather than silently
mangled. Empty content is refused (the API refuses it too). A file **path** is never accepted: an SDK that
opens arbitrary paths on your behalf becomes a local-file-read primitive the
moment one of those paths comes from an HTTP request. Read the file yourself.

## Options

```python
nm = Naijamail(
    api_key=None,             # else NAIJAMAIL_API_KEY
    base_url=None,            # else NAIJAMAIL_BASE_URL, else https://api.naijacloud.com
    timeout=30.0,             # seconds, a deadline for each whole attempt; must be > 0
    max_retries=2,            # 3 attempts in total; 0 to 10
    user_agent_suffix=None,   # appended to nc-email-python/<version> (python/<version>)
)
```

A blank `NAIJAMAIL_BASE_URL` counts as unset. A `base_url` carrying a query
string or a fragment is refused rather than silently stripped. `timeout` bounds
connecting, sending and reading the whole response together — a server that
trickles bytes cannot keep an attempt alive past it — and each retry gets a fresh
deadline.

A client owns its key, its base URL and its HTTP opener. Nothing is held at
module level, so two clients with two keys in one process cannot interfere.

## Errors

Every failure is a subclass of `NaijamailError`, carrying `message`,
`status_code`, `error` (the server's short label), `request_id` (from
`x-request-id`), `raw_body` (the response text exactly as received, `""` for a
local error) and `parsed_body` (the decoded JSON, or `None` if it was not JSON).
`body` is kept for compatibility: the parsed JSON when there was some, otherwise
the text.

| HTTP | Exception | Retried |
| --- | --- | --- |
| 400 | `ValidationError` (`NotFoundError` when the message is `message not found`) | no |
| 401 | `AuthenticationError` | no |
| 403 | `NaijamailPermissionError` | no |
| 404 | `NotFoundError` | no |
| 408 | `NaijamailTimeoutError` | yes |
| 409 | `ConflictError` | no |
| 413, 422 | `ValidationError` | no |
| any other 4xx (405, 415, 451…) | `ValidationError` | no |
| 429 | `RateLimitError` (`.retry_after` in seconds, at most 60) | yes |
| 5xx | `ServerError` | yes |
| socket / DNS / TLS | `NaijamailConnectionError` | yes |
| client-side deadline | `NaijamailTimeoutError` | yes |
| 2xx that is not JSON, or a send response with no `id` | `ServerError` ("malformed response") | no |
| caught before sending | `ValidationError` (`status_code == 0`) | no |

```python
from nc_email import Naijamail, NaijamailError, RateLimitError

try:
    nm.emails.send(from_="hello@acme.com", to="customer@example.com", subject="Hi")
except RateLimitError as exc:
    print("slow down for", exc.retry_after, "seconds")
except NaijamailError as exc:
    print(exc.status_code, exc.message, exc.request_id)
```

`ConnectionError`, `TimeoutError` and `PermissionError` are Python builtins, so
the canonical class names are prefixed — `NaijamailConnectionError`,
`NaijamailTimeoutError`, `NaijamailPermissionError`. The short spellings exist
as aliases (`nc_email.TimeoutError`) but are kept out of `__all__`, so a
`from nc_email import *` cannot quietly replace a builtin in your module and
change the meaning of an unrelated `except`.

Retrieving an id that does not exist raises `NotFoundError` even though the API
answers `400` for it. That is a known server quirk, mapped here so your `except`
reads the way you expect.

## Retries

Three attempts by default, 30 seconds each. Retried on `429`, `408`, any `5xx`,
and connection or timeout failures. Never on any other `4xx` — a `403` for an
unverified domain will not succeed on the second try.

Backoff is exponential with full jitter (`random(0, min(8s, 0.5s * 2^attempt))`).
A `Retry-After` header overrides it on any retried response — a `503` as well
as a `429` — in either its integer-seconds or its HTTP-date spelling, clamped to
60 seconds. It is exposed as `.retry_after` on the error.

Retrying a `POST` is only safe because of idempotency. If you do not supply an
`idempotency_key`, the SDK generates one UUIDv4 per `send()` call and sends it as
`Idempotency-Key` on every attempt of that call. Without that, a lost response
followed by a retry would send your customer two copies of the same receipt.
A key you supply yourself is used as given and never regenerated.

## Security

The rules the SDK enforces, and why:

- **HTTPS only.** A `base_url` that is not `https` is rejected at construction,
  unless the host is `localhost`, `127.0.0.1` or `::1`. A plaintext base URL
  would put a live sending credential on the wire in clear.
- **Redirects are refused, never followed.** urllib's default handler replays
  the original request — `Authorization` and all — at whatever host `Location`
  names. A 3xx surfaces as `ServerError("unexpected redirect")`.
- **TLS verification is explicit.** The SDK builds its own
  `ssl.create_default_context()` with certificate and hostname checks forced on,
  so its behaviour does not depend on a process-wide default someone loosened.
- **The key never leaves except in the auth header.** `repr()`, `str()` and the
  User-Agent all show `nmail_live_***`; the key is not an attribute of the
  client object; the client refuses to pickle.
- **Header-injection defence.** CR, LF or NUL in `from`, any address, `subject`,
  a custom header name or value, an attachment filename, `content_type` or
  `content_id`, or the idempotency key
  is rejected before any network call.
- **Client-side limits**, mirroring the server: 50 recipients across
  `to`+`cc`+`bcc`, 10 MiB of message, 25 headers, 10 tags. The size is
  measured the way the server measures it: `html` and `text` as UTF-8 plus the
  **decoded** attachment bytes — so a 9 MiB attachment goes through even though
  it is ~12 MiB once base64-encoded.
- **Key shape is checked at construction** (`nmail_live_…`, `nmail_test_…` or
  `nc_live_…`, surrounding whitespace trimmed), so a bad key fails at deploy
  rather than as a 401 during a customer's checkout.

A **test** key (`nmail_test_…`) is sandboxed: sends are recorded and answered
with a real id, never delivered, so staging and CI can exercise the whole flow
without mailing anyone. `SendEmailResponse.sandbox` and `Email.sandbox` are true
for those messages.

### Which key

Two kinds work, and the SDK cannot tell them apart once it has one:

- **`nc_live_…`** — a workspace API key from **Settings → API keys**, ticked for
  the **Email send** scope. Most teams already have one: it is the same
  credential CI deploys with. Add **Platform API** as well if the key also needs
  to manage sending domains or suppressions.
- **`nmail_live_…` / `nmail_test_…`** — a Naijamail-only key from **Email**. The
  test variant is **sandboxed**: the API accepts the send, returns a real id
  and a final status, and never hands the message to a mail server. Use one in
  staging and CI. Send from any domain you have added, or from
  `…@test.mail.naijacloud.dev`; send *to* `delivered@`, `bounced@` or
  `complained@test.mail.naijacloud.dev` to get that outcome. A message sent
  this way comes back from `get` with `sandbox` set to true. There is no test
  variant of a workspace key.

An `nc_pat_…` platform token is not accepted: those predate the Email send scope
and the API refuses them on the mail routes, so the SDK refuses them at
construction rather than a request later, with a message saying it is a personal
access token and which keys to use instead.

Report a vulnerability to security@naijacloud.com. See [SECURITY.md](SECURITY.md).

## Webhooks

> **Live.** Naija Cloud delivers these events to endpoints you register, signed
> exactly as below. Two details this verifier already handles: the timestamp is
> taken per delivery *attempt*, so a retry never arrives outside the tolerance
> window; and during a secret rotation the header carries two `v1=` values for
> 24 hours, which is why any match is accepted.

```python
from nc_email import Webhooks, WebhookVerificationError

@app.post("/webhooks/naijamail")
def handle():
    try:
        event = Webhooks.verify(
            request.get_data(),                    # the raw body bytes, not a parsed dict
            request.headers.get("NC-Signature"),
            os.environ["NAIJAMAIL_WEBHOOK_SECRET"],
        )
    except WebhookVerificationError:
        return "", 400
    return "", 204
```

The header is `NC-Signature: t=<unix seconds>,v1=<hex sha256 hmac>`, over
`"<t>.<raw body>"`. Several `v1=` values may be present during a secret
rotation, and any one matching is enough. Timestamps outside 300 seconds are
rejected, which is what stops a captured request being replayed later;
`tolerance=0` means strict (this second only), not "the default". `t` must be
plain ASCII digits (at most 12), the hex signature is compared
case-insensitively, and the payload must be a JSON object. The comparison is
constant-time, and a failure never tells you the expected signature.

Pass the **raw bytes**. A parsed object re-serialised differs from what was
signed by key order and spacing, so it would fail verification at random.

## Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md). The test suite needs no network and no
third-party packages:

```bash
python -m unittest discover -s tests -v
```

## License

MIT. Copyright (c) 2026 Naija Cloud.
