Nafru Email Python SDK
The official Nafru Email Python SDK (nafru-email) for the Nafru Email API, a transactional and marketing email API. Use it to
send email from Python, manage domains, contacts, templates and broadcasts, and verify webhooks. The API is Resend-compatible,
so request shapes, field names and errors match Resend. The SDK has zero dependencies: it uses only the standard library (urllib, hmac,
hashlib) and ships full type hints.
Requirements
Python 3.9 or later.
Installation
pip install nafru-email
Quick start: send an email
from nafru_email import NafruEmail, NafruError
nafru = NafruEmail("nafru_...") # or NafruEmail() to read the NAFRU_API_KEY environment variable
# Optional settings: NafruEmail("nafru_...", base_url="http://localhost:48787/api", max_retries=2, timeout=30, dev_outbox=False)
# base_url defaults to DEFAULT_BASE_URL (https://email.nafru.com/api).
try:
data = nafru.emails.send(
{
"from": "Acme <hello@example.com>",
"to": "jane@example.org",
"subject": "Welcome",
"html": "<p>Hello!</p>",
"scheduled_at": "in 1 hour", # optional: ISO 8601 or a relative expression, up to 30 days ahead
},
idempotency_key="signup-1042", # retrying with the same key returns the same email
)
print(data["id"])
except NafruError as error:
print(error.status_code, error.name, error.message)
Body fields use snake_case exactly as the API does (reply_to, scheduled_at, first_name); there is no conversion layer. Requests and
responses are TypedDicts (nafru_email.types), so they go in and come out as plain dicts.
No sending in development
When NODE_ENV=development, emails.send and emails.batch (and emails.send on a connection client) do not send anything. Each email is
written under .nafru/emails/ as plain text (.txt, headers + body) plus an .html file with the same name, and the file paths and plain
text are printed to the console. The call returns a dev_… id. Change the folder with dev_outbox="tmp/mail", or turn this off with
dev_outbox=False.
Errors
Every failed call raises NafruError:
error.status_code # int | None — None on network errors and timeouts
error.name # validation_error, not_found, rate_limit_exceeded, application_error, timeout_error, …
error.message
error.errors # [{"field": ..., "message": ...}] — per-field details on 422
error.retry_after # seconds, on 429
Retries, timeouts and idempotency
429is retried on every method, honoringretry-after(the request was rejected before being processed; the wait is capped at 8 s).GET/DELETEare retried on5xxand network errors (exponential backoff starting at 300 ms).POST/PATCHare not retried on server errors, because an email could go out twice; they are retried if you passidempotency_key.- Defaults: 2 retries (
max_retries) and a 30 s timeout (timeout, in seconds). - For tests or a proxy, pass your own transport with
transport=: a function that takes anHttpRequestand returns anHttpResponse.
Resources
emails |
send batch get list update cancel |
domains |
create get list update remove verify |
contacts |
create get list update remove, segments.{list,add,remove}, topics.{list,update} (accepts an id or an email address) |
contact_properties |
create get list update remove |
segments |
create get list remove contacts |
topics |
create get list update remove |
broadcasts |
create get list update remove send cancel |
templates |
create get list update remove publish duplicate put_translation remove_translation (id or alias) |
brand |
get update: workspace branding; update replaces the whole brand, so read it with get first |
webhooks |
create get list update remove |
api_keys |
create list remove |
events |
send batch list: custom events that trigger automations |
Pagination
Lists are cursor-based (limit, after, before keyword arguments). To walk every page:
from functools import partial
from nafru_email import paginate
for contact in paginate(nafru.contacts.list, limit=100):
print(contact["email"])
# Lists that take an id:
for topic in paginate(partial(nafru.contacts.topics.list, "jane@example.org")):
print(topic["name"])
If a page fails, the generator raises NafruError instead of stopping silently.
Sending with a template
nafru.templates.create({
"name": "Welcome",
"alias": "welcome",
"subject": "Welcome {{{NAME|friend}}}",
"html": "<html><body><p>Hello {{{NAME|friend}}}</p></body></html>",
"brand": {"mode": "inherit"},
})
nafru.templates.publish("welcome")
nafru.emails.send({
"from": "Acme <hello@acme.example>",
"to": "jane@example.org",
"template": {"id": "welcome", "variables": {"NAME": "Jane"}},
})
Recurring broadcasts and data sources
nafru.broadcasts.create({
"segment_id": "seg_1",
"from": "Acme <hello@acme.example>",
"html": "<p>This week's plan: {{{data.plan}}}</p>",
"recurrence": {"type": "weekly", "days": [1], "time": "09:00"}, # or monthly{day, time}, interval{every_days, time}
"data_source": {"type": "url", "url": "https://acme.example/weekly.json"}, # manual | url | webhook
})
nafru.broadcasts.update("b_1", {"recurrence": None}) # None clears a field; omitted fields stay unchanged
Custom events
nafru.events.send({
"name": "plan_upgraded",
"contact": {"email": "jane@example.org"}, # created if it does not exist
"properties": {"plan": "pro", "seats": 3},
})
Verifying webhook signatures
The server signs webhooks in the Svix/Standard Webhooks format. Use the raw body; parsed and re-serialized JSON breaks the signature.
from nafru_email import verify_webhook
ok = verify_webhook(
request.get_data(), # raw body (bytes or str)
{
"id": request.headers["svix-id"],
"timestamp": request.headers["svix-timestamp"],
"signature": request.headers["svix-signature"],
},
os.environ["NAFRU_WEBHOOK_SECRET"], # signing_secret from the webhooks.create response
)
if not ok:
return "invalid signature", 400
Timestamps more than 5 minutes in the past or future are rejected (tolerance_seconds). During key rotation several signatures may be sent;
one valid signature is enough. A malformed whsec_ secret raises ValueError (a configuration error).
Connect with Nafru (for apps)
If you want your own product's customers to send from their own Nafru Email account, do not ask them to copy an API key: register your app
in Nafru Email (Developer → Apps) and use NafruConnect.
import os
from nafru_email import NafruConnect
connect = NafruConnect(
client_id=os.environ["NAFRU_CLIENT_ID"],
client_secret=os.environ["NAFRU_CLIENT_SECRET"], # server-side only
# base_url is optional (defaults to https://email.nafru.com/api; locally "http://localhost:48787/api")
# web_url: the consent screen; if omitted, the trailing /api is stripped from base_url
)
# 1) Redirect the customer. Store code_verifier and state in the session.
auth = connect.authorize_url(
redirect_uri="https://myapp.example/nafru/callback",
state=state,
scopes=["emails.send", "templates.read", "senders.read", "connection.read"],
)
redirect(auth["url"])
# 2) On return, verify state and exchange the code for a connection.
grant = connect.exchange(code=code, code_verifier=auth["code_verifier"], redirect_uri=redirect_uri)
# Map grant.connection_id to the customer; store grant.refresh_token ENCRYPTED (repr() masks it).
# 3) Call on the customer's behalf. The access token is refreshed automatically; the refresh token CHANGES on every refresh.
connection = connect.connection(
connection_id,
refresh_token=load(connection_id),
on_token_refresh=lambda tokens: save(connection_id, tokens.refresh_token), # the old token is invalid once rotated, so persist it
)
status = connection.status() # ready, missing: ['domain', 'sender', …], setup_url
connection.senders.list()
connection.templates.list() # the customer's templates + your app's bundle
connection.emails.send({"from": sender, "to": to, "subject": "…", "template": {"id": "welcome", "variables": {}}})
- If the customer disconnected or the refresh token is invalid,
NafruError.name == "connection_revoked"; ask the customer to reconnect. - Concurrent threads using the same connection object share a single refresh (single flight). If several processes use the same connection, keep the refresh token in a shared, consistent store and serialize refreshes with a lock: reusing an old token revokes the connection for security.
- If
on_token_refreshraises, the call raisestoken_persist_failed; the new token stays in memory and the next call retries saving it. - Management endpoints cannot be called with a connection token; only sending, reading and discovery are available.
- When the customer closes their account:
connect.revoke(refresh_token=...).
Deprecation warnings
When the API deprecates this SDK version, responses carry a Deprecation header; a warning is printed to stderr once per client (with the
sunset date and a details link when present). The call itself is not affected.
Deprecated versions are listed in deprecations.json.
Security
- Keep keys server-side. Never put the API key or client secret in browser or mobile code; even a
sending_accesskey can send email. - HTTPS is enforced.
base_urlandweb_urlmust usehttps; plainhttpis accepted only forlocalhost,*.localhost,127.0.0.0/8and[::1](local development). Certificate verification is always on and cannot be disabled. - Redirects are refused. The API never redirects, so a
3xxresponse raisesNafruErrorwith nameunexpected_redirectinstead of forwarding yourAuthorizationheader to another host. - Input checks. Empty,
.and..path parameters andidempotency_keyvalues with control characters (CR/LF) raiseinvalid_argumentbefore any request is sent. Response bodies are capped at 10 MB (response_too_large). - Secrets are redacted.
repr()ofNafruEmail,NafruConnect,ConnectionGrant,ConnectionTokensandHttpRequestmasks the API key, client secret and tokens (nafru_****); errors never include them. - Verify webhooks on the raw body. Pass the exact bytes/string you received to
verify_webhook; the comparison is constant-time and the timestamp must be within 5 minutes in either direction. - Reporting a vulnerability: see SECURITY.md. Report privately through GitHub private vulnerability reporting or email security@nafru.com. Please do not open a public issue.
Development
uv run --with pytest pytest # unit tests (no network, fake transport)
uv build # dist/ (sdist + wheel)
The single source of the version is SDK_VERSION in src/nafru_email/__init__.py (pyproject.toml reads it).
Troubleshooting
Every failed call raises NafruError; check error.name first.
error.name / symptom |
What it means and what to do |
|---|---|
unauthorized / invalid_api_key |
The key is wrong or revoked, or NafruEmail() was called without a key and NAFRU_API_KEY is not set (that case raises ValueError at construction). Create a new key in the dashboard. |
forbidden |
The key lacks permission, e.g. a sending_access key can send but cannot list or manage resources. Use a full_access key for management calls. |
validation_error |
The request body is invalid. error.errors lists each failing field and message. |
rate_limit_exceeded |
Too many requests. The SDK already retried (max_retries, honoring retry-after); error.retry_after says how many seconds to wait. |
timeout_error / application_error with status_code None |
Network failure or timeout (timeout, default 30 s). GET/DELETE are retried automatically; a POST is retried only with an idempotency_key, so pass one to retry sends safely without duplicates. |
unexpected_redirect |
The API never redirects: base_url is wrong (missing /api, wrong host) or a proxy redirects. Redirects are not followed so your key is not leaked. |
ValueError: base_url https kullanmalı (http yalnız localhost için) |
base_url / web_url must be https; plain http is allowed only for localhost, *.localhost, 127.0.0.0/8 and [::1]. |
response_too_large |
The response exceeded 10 MB, usually a misbehaving proxy or the wrong base_url. |
invalid_argument |
An empty, . or .. id, or an idempotency_key that is empty or contains control characters. |
connection_revoked (Connect) |
The customer disconnected your app or the refresh token is invalid/reused. Ask the customer to connect again. |
token_persist_failed (Connect) |
Your on_token_refresh callback raised. The new refresh token is kept in memory and saving is retried on the next call; fix storage before the process restarts. |
verify_webhook returns False |
Pass the raw request body (not re-serialized JSON), the endpoint's whsec_… signing secret, and the svix-id, svix-timestamp, svix-signature headers. Check the server clock: timestamps more than 5 minutes off are rejected. |
| Emails are not being sent | NODE_ENV=development turns on the dev outbox: emails are written to .nafru/emails/ instead of sent. Pass dev_outbox=False or unset NODE_ENV. |
Links
| Language | Package | Source |
|---|---|---|
| TypeScript | @nafrucom/email (npm) |
npm |
| Python | nafru-email (PyPI) |
python |
| Go | github.com/nafrucom/nafru-email-sdks/go |
go |
| PHP | nafrucom/email (Packagist) |
php |
| Ruby | nafru-email (RubyGems) |
ruby |
| Java | com.nafru:nafru-email (Maven Central) |
java |
| .NET | Nafru.Email (NuGet) |
dotnet |
| Rust | nafru-email (crates.io) |
rust |
| Swift | SwiftPM | swift |
- API documentation: https://email.nafru.com
- All SDKs: github.com/nafrucom/nafru-email-sdks
Support
- Questions and ideas: GitHub Discussions
- Bugs in this SDK: GitHub issues
- Account, billing and deliverability: destek@nafru.com
- Security issues: SECURITY.md (never in a public issue)
See SUPPORT.md for details and CONTRIBUTING.md to contribute.
License
MIT. See LICENSE.
Metadata
Release files for nafru-email 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| nafru_email-0.1.1.tar.gz | 36.4 kB | Without provenance | Details for nafru_email-0.1.1.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| nafru_email-0.1.1-py3-none-any.whl | Python 3 | none | any | Details for nafru_email-0.1.1-py3-none-any.whl |
Total release size: 69.9 kB
Release files / nafru_email-0.1.1.tar.gz
| Download URL | nafru_email-0.1.1.tar.gz |
|---|---|
| Size | 36.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
80a68fbfc2b2fb4a7ab46aa9ce1db5b043a4cecedfa922a9c6483bece70786f2
|
|
BLAKE2b-256 checksum How to use checksums |
1bde563f411b86e0a0649e2bd19bfc7ce382e5ab274654326e2f86fe988385c9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.9.30 {"installer":{"name":"uv","version":"0.9.30","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"12","id":"bookworm","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / nafru_email-0.1.1-py3-none-any.whl
| Download URL | nafru_email-0.1.1-py3-none-any.whl |
|---|---|
| Size | 33.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d5aa0c3d377407c7dce01e8e61b202d189f63aa47d2785319c48b8f53e006516
|
|
BLAKE2b-256 checksum How to use checksums |
4839910ea030adffbeba74713e4bb85f348b210d90c6048b9581aa075b9af21d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.9.30 {"installer":{"name":"uv","version":"0.9.30","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"12","id":"bookworm","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Security for nafru-email 0.1.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.