Official Python SDK for the Maildeno render API
Project description
maildeno
Official Python SDK for the Maildeno render API.
Fetches template JSON from the Maildeno server, caches it locally (memory or disk), and renders HTML / React Email TSX / MJML in-process using an embedded Wasm engine — so your merge tags and dynamic data never leave your server.
Sync and async clients, full type hints, single runtime dependency (httpx).
Table of contents
- Installation
- Quick start
- Configuration
- How caching works
- Rendering
- Async client
- Dynamic data
- Error handling
- Framework examples
- TypedDict reference
- Changelog
Installation
pip install maildeno
# or
poetry add maildeno
# or
uv add maildeno
Requirements: Python 3.9+. Runtime dependencies: httpx >= 0.25, < 1.0 and wasmtime.
Quick start
Sync:
from maildeno import MaildenoClient
client = MaildenoClient(api_key="sk_live_4a7f2c8d...")
# Render to HTML — template is fetched from Maildeno, cached locally,
# and rendered in-process with the embedded Wasm engine.
html = client.render_html("550e8400-e29b-41d4-a716-446655440000")
print(html) # <!DOCTYPE html>...
Async:
import asyncio
from maildeno import AsyncMaildenoClient
async def main():
async with AsyncMaildenoClient(api_key="sk_live_4a7f2c8d...") as client:
html = await client.render_html("550e8400-e29b-41d4-a716-446655440000")
print(html)
asyncio.run(main())
Configuration
from maildeno import MaildenoClient
client = MaildenoClient(
# ── Required ────────────────────────────────────────────────────────────
api_key="sk_live_...",
# Obtain from: Maildeno Dashboard → API Keys → Create Key
# ── Optional ────────────────────────────────────────────────────────────
# Base URL of the Maildeno API. Defaults to "https://api.maildeno.com".
# Override only when proxying through your own infrastructure or running
# a self-hosted Maildeno instance.
base_url="https://maildeno-proxy.yourcompany.com", # OPTIONAL
# Request timeout in seconds. Defaults to 30.0.
timeout=10.0, # OPTIONAL
# Cache configuration. Omit to use memory caching with default settings.
# See "How caching works" for the full explanation.
cache={
# "memory" (default) or "disk"
"type": "memory", # OPTIONAL — defaults to "memory"
# Directory for disk cache. Required when type is "disk".
# Created automatically if it doesn't exist.
"path": "/var/cache/maildeno", # OPTIONAL — only used when type="disk"
# How long a cached template is considered fresh (milliseconds).
# After TTL expires a re-fetch is attempted.
# Stale copy is used as fallback if the server is unreachable.
"ttl": 300_000, # OPTIONAL — defaults to 300_000 (5 min)
# Max template entries before the oldest is evicted.
"max_entries": 50, # OPTIONAL — defaults to 50
},
)
Tip: Instantiate the client once at application startup and reuse it across requests. The cache and the Wasm engine instance are both attached to the client. For
AsyncMaildenoClient, use as a context manager or callawait client.aclose()during shutdown.
How caching works
Cache sequence (Maildeno server → your project)
When you call any render method, the SDK follows this sequence:
Your code
│
▼
┌──────────────────────────────────────┐
│ client.render_html(template_id) │
│ │
│ 1. Check in-process memory cache ◄──┼── CACHE HIT → render immediately (0 ms)
│ │ │
│ cache miss ▼ │
│ │
│ 2. Check disk cache (if configured) │
│ read <path>/<id>.json ◄───┼── CACHE HIT → load into memory, render
│ │ │
│ cache miss ▼ │
│ │
│ 3. Fetch template JSON from │
│ GET https://api.maildeno.com │
│ /v1/sdk/template/<id> ◄───┼── NETWORK CALL (only on first render
│ │ │ or after TTL expiry)
│ store in cache ▼ │
│ │
│ 4. Render template + dynamic data │
│ in-process with Wasm engine │
│ (merge tags & context never │
│ leave your server) │
└──────────────────────────────────────┘
│
▼
rendered HTML / TSX / MJML string
Key points:
- The network call to Maildeno happens at most once per template per TTL window.
- After the first fetch, every subsequent render is pure CPU — no I/O, no latency.
- The Wasm engine processes your merge tags locally. Dynamic data is never sent to Maildeno's servers.
AsyncMaildenoClientdispatches the Wasm render toasyncio's thread-pool executor so the event loop is never blocked.
Memory cache
The default. Zero configuration required. Lives in the process heap.
Thread-safe: a threading.Lock ensures exactly-once Wasm engine initialisation.
# Defaults — no cache config needed
client = MaildenoClient(api_key="sk_live_...")
# Explicit memory config with custom TTL
client = MaildenoClient(
api_key="sk_live_...",
cache={
"type": "memory", # OPTIONAL — "memory" is the default
"ttl": 60_000, # OPTIONAL — 60 s instead of 5 min
"max_entries": 100, # OPTIONAL — hold up to 100 templates
},
)
When to use: Always — unless your process restarts frequently (e.g. AWS Lambda, Cloud Run) and you want the cache to survive cold starts.
Trade-off: Lost on process exit. Each worker process (Gunicorn, uWSGI) maintains its own independent cache.
Disk cache
Persists template JSON to the local filesystem. Survives process restarts and is shared by all workers pointing at the same directory.
client = MaildenoClient(
api_key="sk_live_...",
cache={
"type": "disk",
"path": "/var/cache/maildeno", # OPTIONAL — defaults to ".maildeno-cache"
"ttl": 600_000, # OPTIONAL — 10 min
"max_entries": 200, # OPTIONAL
},
)
Each template is stored as a JSON file: <path>/<template-id>.json.
Writes are atomic (temp file + os.replace) so concurrent Gunicorn workers
safely write the same files without corruption.
When to use:
- AWS Lambda / Cloud Run / serverless where cold-start latency from a network fetch adds unacceptable overhead.
- Multi-worker Gunicorn / uWSGI deployments sharing a filesystem.
- Long-lived background workers where you want cache persistence across restarts.
Stale-on-error fallback
When a cached template's TTL expires, the SDK attempts a re-fetch. If the Maildeno server is unreachable (network error, timeout, 5xx), the SDK:
- Renders from the last known-good cached copy (stale entry).
- Sets
result.from_stale_cache = Trueon the returnedRenderResult. - Does not raise — your send pipeline continues uninterrupted.
The SDK only raises when the server is unreachable and no prior cached copy exists for that template (first-ever render with no cache entry).
result = client.render(
template_id="550e8400-e29b-41d4-a716-446655440000",
target="html",
)
if result.from_stale_cache:
# OPTIONAL — log or alert that Maildeno was unreachable.
# The output is still valid HTML rendered from the last cached template.
logger.warning(
"Rendered from stale cache — Maildeno may be down",
extra={"template_id": result.template_id},
)
Cache inspection & management
# List template IDs currently held in the cache
ids = client.list_cached()
# ["550e8400-...", "9ec0c043-..."]
# Remove a single template immediately (e.g. after updating it on the dashboard)
client.delete_cached("550e8400-e29b-41d4-a716-446655440000")
# Wipe the entire cache
client.clear_cache()
Rendering
render() — full control
from maildeno import MaildenoClient
from maildeno import RenderResult
client = MaildenoClient(api_key="sk_live_...")
result: RenderResult = client.render(
template_id="550e8400-e29b-41d4-a716-446655440000", # required
target="html", # "html" | "react-email" | "mjml" — OPTIONAL, defaults to "html"
dynamic_data={ # OPTIONAL — omit if your template has no dynamic content
"merge_tags": {
"text": {"first_name": "Noruwa", "company": "Maildeno"},
"url": {"unsubscribe_url": "https://example.com/unsub"},
"attr": {"logo_alt": "Maildeno logo"},
},
"context": {
"plan_name": "Pro", # str — show plan-specific block
},
},
)
print(result.output) # rendered string
print(result.target) # "html"
print(result.template_id) # "550e8400-..."
print(result.from_stale_cache) # False (or True if server was unreachable)
Convenience methods
These return the rendered string directly — no need to access result.output.
# Render to HTML
html = client.render_html(
"550e8400-e29b-41d4-a716-446655440000",
dynamic_data={ # OPTIONAL — same shape as render()
"merge_tags": {"text": {"name": "Noruwa"}},
},
)
# Render to React Email TSX
tsx = client.render_react(
"550e8400-e29b-41d4-a716-446655440000",
dynamic_data={ # OPTIONAL
"merge_tags": {"text": {"name": "Noruwa"}},
},
)
# Render to MJML
mjml = client.render_mjml(
"550e8400-e29b-41d4-a716-446655440000",
dynamic_data={ # OPTIONAL
"merge_tags": {"text": {"name": "Noruwa"}},
},
)
Convenience vs
render(): Use the convenience methods when you only need the output string. Userender()when you needresult.from_stale_cache,result.target, orresult.template_id.
Detecting cache vs server render
Use render() (not the convenience methods) to inspect from_stale_cache.
import logging
from maildeno import MaildenoClient
logger = logging.getLogger(__name__)
client = MaildenoClient(
api_key="sk_live_...",
cache={"ttl": 300_000},
)
def send_welcome_email(user: dict):
result = client.render(
template_id="550e8400-e29b-41d4-a716-446655440000",
target="html",
dynamic_data={ # OPTIONAL
"merge_tags": {
"text": {"first_name": user["name"]},
},
"context": {
"is_new_user": True, # OPTIONAL — visibility context
},
},
)
# ── Cache diagnostic (OPTIONAL) ──────────────────────────────────────
if result.from_stale_cache:
# Rendered from cached copy — Maildeno was unreachable.
# Output is still valid; log for monitoring only.
logger.warning(
"[maildeno] stale cache used",
extra={"template_id": result.template_id, "user_id": user["id"]},
)
else:
logger.debug("[maildeno] rendered fresh", extra={"template_id": result.template_id})
# ── End cache diagnostic ──────────────────────────────────────────────
email_provider.send(
to=user["email"],
subject="Welcome!",
html=result.output,
)
Async client
AsyncMaildenoClient mirrors MaildenoClient exactly, with async/await.
All methods are awaitable. The Wasm render runs in asyncio's thread executor
so the event loop is never blocked.
import asyncio
from maildeno import AsyncMaildenoClient, MaildenoError
async def main():
# Use as a context manager for automatic cleanup
async with AsyncMaildenoClient(
api_key="sk_live_...",
cache={"ttl": 300_000}, # OPTIONAL
) as client:
# render() — full result
result = await client.render(
template_id="550e8400-e29b-41d4-a716-446655440000",
target="html",
dynamic_data={ # OPTIONAL
"merge_tags": {"text": {"name": "Noruwa"}},
},
)
if result.from_stale_cache: # OPTIONAL
print("stale cache used")
# Convenience methods
html = await client.render_html("template-id")
tsx = await client.render_react("template-id")
mjml = await client.render_mjml("template-id")
# Cache management — also async
ids = await client.list_cached()
await client.delete_cached("template-id")
await client.clear_cache()
asyncio.run(main())
Without context manager — call aclose() during shutdown:
client = AsyncMaildenoClient(api_key="sk_live_...")
# Use client across your app ...
# On shutdown:
await client.aclose()
Dynamic data
All fields are optional. Include only what your template actually uses.
Merge tags
Merge tags replace placeholders in the template's content.
dynamic_data = {
"merge_tags": {
# Text values — inserted as plain text
"text": {
"first_name": "Noruwa",
"last_name": "Obaseki",
"company_name": "Maildeno",
"plan_name": "Pro",
},
# URL values — used in link hrefs / button targets
"url": {
"cta_url": "https://app.example.com/dashboard",
"unsubscribe_url": "https://example.com/unsub?token=abc",
"logo_url": "https://cdn.example.com/logo.png",
},
# Attribute values — used in HTML attributes (alt, title, etc.)
"attr": {
"logo_alt": "Maildeno Inc.",
"banner_title": "Summer Sale",
},
},
}
Visibility context
Context values drive show/hide rules configured in the Maildeno template editor. Rows and blocks are conditionally shown based on these values — without touching any template code.
dynamic_data = {
"context": {
"is_premium": True, # bool — show premium upsell row
"plan_name": "Pro", # str — show plan-specific content block
"days_left": 3, # int — show trial-expiry warning when < 7
"has_balance": False, # bool — hide payment-due row when False
},
}
Error handling
All SDK errors are instances of MaildenoError, which extends Exception.
from maildeno import MaildenoClient, MaildenoError
client = MaildenoClient(api_key="sk_live_...")
try:
html = client.render_html("550e8400-e29b-41d4-a716-446655440000")
# use html ...
except MaildenoError as err:
print(err.code) # "TEMPLATE_NOT_FOUND" | "INVALID_API_KEY" | ...
print(err.status) # HTTP status: 401 | 403 | 404 | 422 | 0 (network errors)
print(err.message) # Human-reNoruwable description
# OPTIONAL — structured validation issues (only on RENDER_ERROR / 422)
if err.issues:
for issue in err.issues:
field = ".".join(str(p) for p in issue.get("loc", []))
print(f" Field: {field} — {issue.get('msg')}")
Error codes
| Code | HTTP | When it happens |
|---|---|---|
INVALID_API_KEY |
401 | API key is missing, malformed, or revoked |
FORBIDDEN |
403 | Key lacks scope for this target, or plan limit reached |
TEMPLATE_NOT_FOUND |
404 | template_id does not exist in your account |
RENDER_ERROR |
422 | Template render failed — check err.issues for field details |
NETWORK_ERROR |
0 | httpx raised a connection-level exception |
TIMEOUT |
0 | Request exceeded the configured timeout |
UNKNOWN |
varies | Unexpected response from the server |
Per-error-type handling
import time
import logging
from maildeno import MaildenoClient, MaildenoError
logger = logging.getLogger(__name__)
client = MaildenoClient(api_key="sk_live_...")
def render_with_fallback(template_id: str, fallback_html: str) -> str:
try:
return client.render_html(template_id)
except MaildenoError as err:
if err.code == "TEMPLATE_NOT_FOUND":
# OPTIONAL — fall back to a hardcoded HTML string
logger.warning(f"Template {template_id} not found, using fallback")
return fallback_html
if err.code == "INVALID_API_KEY":
# Configuration error — alert immediately, do not retry
raise RuntimeError("Maildeno API key is invalid. Check MAILDENO_API_KEY.") from err
if err.code == "FORBIDDEN":
# Key doesn't have access to this render target
raise RuntimeError(f"Access denied: {err.message}") from err
if err.code == "RENDER_ERROR":
# Template has invalid merge tag references or bad config
# OPTIONAL — log structured issues for debugging
logger.error("Render failed: %s", err.issues)
raise
if err.code in ("TIMEOUT", "NETWORK_ERROR"):
# Transient — OPTIONAL: retry once after a short delay
logger.warning("Maildeno unreachable, retrying in 2s...")
time.sleep(2)
return client.render_html(template_id) # retry once
raise # unknown error — re-raise
Global error handler pattern
# lib/maildeno.py — centralise client & error handling for your whole app
import logging
import os
from maildeno import MaildenoClient, MaildenoError
logger = logging.getLogger(__name__)
# Instantiate once — the cache lives on this object
maildeno = MaildenoClient(
api_key=os.environ["MAILDENO_API_KEY"],
cache={
"type": "disk", # OPTIONAL — persist across restarts
"path": "/tmp/maildeno-cache",
"ttl": 300_000,
},
)
def safe_render(
template_id: str,
*,
target: str = "html",
dynamic_data: dict | None = None, # OPTIONAL
) -> str | None:
"""
Render a template with consistent error handling.
Returns None on non-fatal errors (network, timeout) so your email
pipeline can skip gracefully instead of crashing.
"""
try:
result = maildeno.render(
template_id=template_id,
target=target,
dynamic_data=dynamic_data,
)
if result.from_stale_cache: # OPTIONAL diagnostic
logger.warning("[maildeno] stale cache used for %s", template_id)
return result.output
except MaildenoError as err:
if err.code in ("TIMEOUT", "NETWORK_ERROR"):
logger.error("[maildeno] unreachable — skipping render: %s", err.message)
return None # OPTIONAL — return None instead of crashing
raise # fatal errors bubble up
Framework examples
FastAPI
# main.py
import os
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from maildeno import AsyncMaildenoClient, MaildenoError
logger = logging.getLogger(__name__)
# Hold a reference at module level — initialised in lifespan
maildeno: AsyncMaildenoClient
@asynccontextmanager
async def lifespan(app: FastAPI):
global maildeno
maildeno = AsyncMaildenoClient(
api_key=os.environ["MAILDENO_API_KEY"],
cache={
"type": "disk", # OPTIONAL — persist across cold starts
"path": "/tmp/maildeno-cache",
"ttl": 300_000,
},
)
yield
await maildeno.aclose()
app = FastAPI(lifespan=lifespan)
class SendRequest(BaseModel):
template_id: str
email: str
name: str
@app.post("/emails/send")
async def send_email(body: SendRequest):
try:
result = await maildeno.render(
template_id=body.template_id,
target="html",
dynamic_data={ # OPTIONAL
"merge_tags": {
"text": {"first_name": body.name},
},
"context": {"is_new_user": True}, # OPTIONAL
},
)
except MaildenoError as err:
# Map SDK error codes to HTTP responses
status_map = {
"INVALID_API_KEY": 500,
"FORBIDDEN": 500,
"TEMPLATE_NOT_FOUND": 404,
"RENDER_ERROR": 500,
"NETWORK_ERROR": 503,
"TIMEOUT": 503,
}
raise HTTPException(
status_code=status_map.get(err.code, 500),
detail={"error": err.code, "message": err.message},
)
if result.from_stale_cache: # OPTIONAL — log stale cache usage
logger.warning("stale cache: %s", result.template_id)
await email_provider.send(to=body.email, subject="Hello!", html=result.output)
return {"sent": True, "from_cache": not result.from_stale_cache}
Django
# myapp/email_utils.py
import os
from maildeno import MaildenoClient, MaildenoError
# Module-level singleton — shared across all Django request threads.
# Thread-safe: the SDK's memory cache uses a threading.Lock internally.
maildeno = MaildenoClient(
api_key=os.environ["MAILDENO_API_KEY"],
cache={
"type": "disk", # OPTIONAL — shared across Gunicorn workers
"path": "/tmp/maildeno-cache",
"ttl": 300_000,
},
)
def send_welcome_email(user):
try:
result = maildeno.render(
template_id=os.environ["WELCOME_TEMPLATE_ID"],
target="html",
dynamic_data={ # OPTIONAL
"merge_tags": {
"text": {
"first_name": user.first_name,
"username": user.username,
},
"url": {
"dashboard_url": f"https://example.com/dashboard/{user.pk}",
},
},
"context": {
"is_new_user": True, # OPTIONAL — visibility context
"email_verified": user.is_active,
},
},
)
except MaildenoError as err:
# Log and re-raise or handle gracefully
import logging
logging.getLogger(__name__).error(
"Maildeno render failed: %s %s", err.code, err.message
)
raise
if result.from_stale_cache: # OPTIONAL diagnostic
import logging
logging.getLogger(__name__).warning(
"Stale cache used for template %s", result.template_id
)
from django.core.mail import send_mail
send_mail(
subject="Welcome!",
message="",
from_email="noreply@example.com",
recipient_list=[user.email],
html_message=result.output,
)
Flask
# app.py
import os
import logging
from flask import Flask, request, jsonify
from maildeno import MaildenoClient, MaildenoError
app = Flask(__name__)
logger = logging.getLogger(__name__)
# Module-level singleton
maildeno = MaildenoClient(
api_key=os.environ["MAILDENO_API_KEY"],
cache={"ttl": 300_000}, # OPTIONAL
)
@app.post("/emails/send")
def send_email():
body = request.get_json()
try:
result = maildeno.render(
template_id=body["template_id"],
target="html",
dynamic_data={ # OPTIONAL
"merge_tags": {
"text": {"name": body.get("name", "")},
},
},
)
except MaildenoError as err:
code_to_status = {
"TEMPLATE_NOT_FOUND": 404,
"NETWORK_ERROR": 503,
"TIMEOUT": 503,
}
return jsonify(error=err.code, message=err.message), code_to_status.get(err.code, 500)
if result.from_stale_cache: # OPTIONAL
logger.warning("stale cache: %s", result.template_id)
email_provider.send(to=body["email"], html=result.output)
return jsonify(sent=True)
Celery task
# tasks.py
import os
import logging
from celery import Celery
from maildeno import MaildenoClient, MaildenoError
logger = logging.getLogger(__name__)
celery_app = Celery("tasks", broker=os.environ["REDIS_URL"])
# Worker-level singleton — one client per Celery worker process
maildeno = MaildenoClient(
api_key=os.environ["MAILDENO_API_KEY"],
cache={
"type": "disk", # OPTIONAL — shared across worker processes
"path": "/tmp/maildeno-cache",
"ttl": 300_000,
},
)
@celery_app.task(bind=True, max_retries=3, default_retry_delay=5)
def send_welcome_email(self, user_id: str, email: str, name: str):
try:
result = maildeno.render(
template_id=os.environ["WELCOME_TEMPLATE_ID"],
target="html",
dynamic_data={ # OPTIONAL
"merge_tags": {
"text": {"first_name": name},
"url": {"activate_url": f"https://example.com/activate/{user_id}"},
},
"context": {"is_new_user": True}, # OPTIONAL
},
)
except MaildenoError as err:
if err.code in ("TIMEOUT", "NETWORK_ERROR"):
# Transient — retry via Celery retry mechanism
raise self.retry(exc=err)
# Fatal errors — do not retry
logger.error("Maildeno fatal error: %s %s", err.code, err.message)
raise
if result.from_stale_cache: # OPTIONAL diagnostic
logger.warning("stale cache used for user %s", user_id)
email_provider.send(to=email, html=result.output)
TypedDict reference
All public types are exported from the package root:
from maildeno import (
MaildenoClient,
AsyncMaildenoClient,
MaildenoError,
)
from maildeno import (
RenderTarget, # Literal["html", "react-email", "mjml"]
RenderResult, # frozen dataclass: template_id, target, output, from_stale_cache
DynamicData, # TypedDict: { merge_tags?, context? }
MergeTagGroup, # TypedDict: { text?, url?, attr? }
ContextValue, # Union[str, int, float, bool]
CacheConfig, # TypedDict: { type?, path?, ttl?, max_entries? }
TemplateJson, # TypedDict: raw shape of GET /v1/sdk/template/{id}
SdkErrorCode, # Literal[...] of error code strings
ValidationIssue, # TypedDict: { loc, msg, type } — entries on err.issues
)
RenderResult fields:
| Field | Type | Description |
|---|---|---|
output |
str |
The rendered string (HTML, TSX, or MJML) |
target |
RenderTarget |
Which target was rendered |
template_id |
str |
UUID of the template |
from_stale_cache |
bool |
True if rendered from stale cache due to server being unreachable |
DynamicData and MergeTagGroup are TypedDicts — you can pass plain dict
literals and the type checker will validate keys/values for you.
Changelog
See CHANGELOG.md for the full version history.
License
MIT
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file maildeno-2.0.3.tar.gz.
File metadata
- Download URL: maildeno-2.0.3.tar.gz
- Upload date:
- Size: 133.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7592a927307ecfb51e0571ff48c9324b8b13a576bdff85a36b2ccd598f6c3cf7
|
|
| MD5 |
e957496795d1ff9a27b1974734724a3a
|
|
| BLAKE2b-256 |
225d3c46ce61d6499aded7dffaa745c627efe2af689e1300bcb95a27995f6616
|
File details
Details for the file maildeno-2.0.3-py3-none-any.whl.
File metadata
- Download URL: maildeno-2.0.3-py3-none-any.whl
- Upload date:
- Size: 124.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b6e1b157f20757029ab0a2431af46f14faad09c9314b19aa22e77a0d0fda396a
|
|
| MD5 |
1e1634d2d80cb7a12daa75b7df4c5955
|
|
| BLAKE2b-256 |
96da3fadd874e9ac43f85cb3ea8628f5b798df25bffbdaa7b2f80b8e83482bd1
|