camelmailer-python
The official Python SDK for Camelmailer — open-source transactional email.
Fully typed (py.typed), sync and async, one dependency (httpx), Python 3.9+.
Install
pip install camelmailer
Quickstart
import camelmailer
client = camelmailer.CamelMailer(api_key="cm_xxxx") # or set CAMELMAILER_API_KEY
result = client.emails.send({
"from": "billing@yourdomain.com",
"to": ["ada@example.com"],
"subject": "Your receipt",
"html_body": "<p>Thanks for your purchase!</p>",
})
print(result["message_id"])
The API key is a server API key (header X-Server-API-Key). If you don't pass
api_key, the client reads the CAMELMAILER_API_KEY environment variable.
Self-hosted / custom instance
The default base URL is the Camelmailer cloud (https://app.camelmailer.com).
Point the client at your own instance:
client = camelmailer.CamelMailer(api_key="cm_xxxx", base_url="https://mail.yourcompany.com")
…or set CAMELMAILER_BASE_URL.
Async
import asyncio, camelmailer
async def main() -> None:
async with camelmailer.AsyncCamelMailer(api_key="cm_xxxx") as client:
await client.emails.send({"from": "hi@yourdomain.com", "to": ["ada@example.com"],
"subject": "Hello", "text_body": "Hi!"})
asyncio.run(main())
Every method below exists on both clients — identical signatures, just await them.
Emails
# Send (attachments, named addresses, tags, custom headers, metadata)
client.emails.send({
"from": {"email": "billing@yourdomain.com", "name": "Acme Billing"},
"to": ["ada@example.com"],
"subject": "Invoice",
"html_body": "<p>Attached.</p>",
"attachments": [{"name": "invoice.pdf", "content_type": "application/pdf",
"data_base64": "aGVsbG8="}],
"tag": "invoice",
})
# Batch
client.emails.send_batch([msg1, msg2, msg3])
# Stored templates (Mustache-style {{ variables }})
client.emails.send_with_template({
"from": "hello@yourdomain.com",
"to": ["ada@example.com"],
"template": "welcome",
"template_model": {"name": "Ada"},
})
client.emails.send_with_template_batch([...])
# Read back
client.emails.get(1234)
client.emails.list(scope="outgoing", status="Sent", tag="invoice", query="ada", page=1)
client.emails.deliveries(1234)
client.emails.opens(1234)
client.emails.clicks(1234)
client.emails.raw(1234) # raw RFC 5322 source
Templates
client.templates.list()
client.templates.create({"name": "welcome", "subject": "Hi {{ name }}",
"html_body": "<p>Hi {{ name }}</p>"})
client.templates.get("welcome")
client.templates.update("welcome", {"subject": "Hello {{ name }}"})
client.templates.render("welcome", {"name": "Ada"}) # preview, does not send
client.templates.archive("welcome")
Streams
client.streams.list()
client.streams.create({"name": "Broadcasts", "stream_type": "broadcast"})
client.streams.get("broadcasts")
client.streams.update("broadcasts", {"name": "Newsletter"})
client.streams.archive("broadcasts")
Retrying safely
Pass an idempotency key and a retry replays the original result instead of
queuing a second copy. Keys are scoped to the server, a completed result is
kept for 24 hours, and reusing one for different content raises
ValidationError rather than being silently ignored.
client.emails.send(
{"from": "billing@acme.com", "to": ["ada@example.com"], "subject": "Your receipt"},
idempotency_key=f"order-{order.id}",
)
client.emails.send_batch(messages, idempotency_key=f"nightly-{today}")
A server can also carry a 30-day send allowance. When it runs out the API
raises SendLimitExceededError before storing anything, so nothing was
queued. It subclasses RateLimitError, so code that already catches that
keeps working.
Broadcast: subscribers and campaigns
Subscribers belong to one stream rather than to a global list, and a broadcast to an address that is not subscribed is refused.
client.subscribers.add("product-news", {"address": "ada@example.com"})
client.subscribers.import_("product-news", ["ada@example.com", "grace@example.com"])
client.subscribers.list("product-news")
client.subscribers.complaint("product-news", "ada@example.com") # suppress + unsubscribe
client.subscribers.remove("product-news", "ada@example.com")
The same content to everyone at once, for small audiences:
result = client.emails.send_to_stream(
"product-news",
{"from": "news@acme.com", "subject": "What shipped", "html_body": "<p>Hello</p>"},
)
# {"queued": ..., "skipped": ...} — recipients past the per-request cap of
# 1000 are skipped, so a larger audience wants a campaign.
A campaign is content plus an audience. There are two ways to create one
and they behave differently, so pick deliberately: create_draft writes it
and waits, create_and_send expands it to the stream's subscribers before
the call returns.
created = client.campaigns.create_draft(
{
"stream": "product-news",
"name": "September newsletter",
"from": "news@acme.com",
"subject": "What shipped in September",
# "scheduled_at": "2026-10-01T08:00:00Z", # arms it as `scheduled`
}
)
campaign_id = created["campaign"]["id"]
# Goes out on the spot, no draft and no schedule:
client.campaigns.create_and_send(
"product-news",
{"name": "Status update", "from": "news@acme.com", "text_body": "All clear."},
)
client.campaigns.update(campaign_id, {"scheduled_at": "2026-10-01T08:00:00Z"}) # -> scheduled
client.campaigns.update(campaign_id, {"scheduled_at": None}) # -> draft
client.campaigns.send(campaign_id)
client.campaigns.cancel(campaign_id)
detail = client.campaigns.get(campaign_id)
detail["stats"] # delivered, failed, opened, clicked, unsubscribed
Layouts
A layout wraps every template that uses it. The HTML wrapper has to embed
the body raw as {{{ content }}}; escaped interpolation would show the
message markup as text, and the API refuses it.
client.layouts.create(
{"name": "Default", "html_wrapper": "<html><body>{{{ content }}}</body></html>"}
)
client.layouts.upload_logo("default", "data:image/png;base64,iVBORw0KGgo=")
client.layouts.list()
client.layouts.delete("default")
Inbound and held mail
client.inbound.list(stream="support-inbox", status="Held")
client.inbound.get(55)
client.inbound.retry(55) # back on the delivery queue
client.inbound.bypass(55) # release past the hold
Request log and tags
Useful when a send did not arrive and the question is whether the request ever reached the API, and with what answer.
client.logs.list(status="4xx", method="POST")
client.logs.tags() # [{"tag": "receipt", "count": 91}, ...]
Stats & bounces
client.stats.get(from_="2026-01-01T00:00:00Z") # str or datetime
client.stats.deliveries()
client.bounces.list(page=1, per_page=25)
client.bounces.get(42)
DMARC
client.dmarc.summary(domain="yourdomain.com")
client.dmarc.reports(domain="yourdomain.com", page=1)
client.dmarc.report(7) # single report incl. records
Error handling
All API errors are typed exceptions carrying the stable error code, the
message and the HTTP status_code:
import camelmailer
try:
client.emails.send({"from": "no@unverified.example", "to": ["x@example.com"]})
except camelmailer.ValidationError as e: # also: AuthenticationError,
print(e.code, e.status_code, e.message) # NotFoundError, RateLimitError
except camelmailer.CamelMailerError as e: # base class for everything else
print(e.code)
Docs
Full API documentation: camelmailer.com/docs
License
Metadata
Release files for camelmailer 0.2.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 | |
|---|---|---|---|---|
| camelmailer-0.2.1.tar.gz | 27.7 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| camelmailer-0.2.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 51.6 kB
Release files / camelmailer-0.2.1.tar.gz
| Download URL | camelmailer-0.2.1.tar.gz |
|---|---|
| Size | 27.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
86db0d052768fcae7eba47fa5298726fa93f69ce0182a5bf9353650a1d9dced1
|
|
BLAKE2b-256 checksum How to use checksums |
b217cbcb176416dcc5cd5ca79e781c69e19a001105a817afa5bf24f83b875ac0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
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.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 14, 2026.
Transparency logRelease files / camelmailer-0.2.1-py3-none-any.whl
| Download URL | camelmailer-0.2.1-py3-none-any.whl |
|---|---|
| Size | 23.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
6aa7bb29ea7f0e1e92c5a5b7ae06b807ec29bd7428828651e72281d1805b9864
|
|
BLAKE2b-256 checksum How to use checksums |
f5e325fca3025a8d27b438b0c66acd83bec9c30f899d9c1e3074e6ec24afb854
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
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.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 14, 2026.
Transparency logSecurity for camelmailer 0.2.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.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for camelmailer-0.2.1.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 14, 2026.
Transparency logA 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.