Skip to main content

bloonio_mail_relay_client

Backend Python SDK for bloonio_mail_relay — the Bloonio email PaaS. Send transactional/marketing email, manage sending domains + DKIM, view received mail, and receive signed webhook events. Framework-agnostic core + a thin FastAPI adapter.

Mirrors bloonio_auth_relay_client / bloonio_chat_relay_client (HMAC#1 signing, singleton accessor, from_env adapter).

Versioned per SemVer — pre-1.0, so breaking changes may land in minor bumps; they're called out with migration notes in CHANGELOG.md.

Install

pip install "bloonio-mail-relay-client[fastapi]"   # for FastAPI tenants
pip install "bloonio-mail-relay-client[django]"    # for Django tenants
pip install bloonio-mail-relay-client              # framework-agnostic core only

Send an email

from bloonio_mail_relay_client import MailRelayClient, MailRelaySettings

client = MailRelayClient(MailRelaySettings(
    base_url="https://mail-relay.example.com",
    tenant_id="...",          # from provisioning
    tenant_secret="sk_...",   # shown once at provisioning
))

res = client.send(
    from_addr="noreply@acme.com",   # any local-part on a verified domain
    from_name="Acme",               # optional From display name -> "Acme" <noreply@acme.com>
    to=["customer@example.com"],
    subject="Welcome",
    html="<h1>Hi!</h1>",
    text="Hi!",
)
print(res.id, res.status)           # -> "...", "sent"

# schedule for later, or cancel a scheduled send:
from datetime import datetime, timedelta, timezone
later = client.send(from_addr="noreply@acme.com", to="x@example.com", subject="Later",
                    text="...", scheduled_at=datetime.now(timezone.utc) + timedelta(hours=1))
client.cancel_email(later.id)

Send a batch

send_batch takes a list of per-message keyword mappings (each the same shape you'd pass to send) and returns one BatchSendResult per input, in order. By default a failing message is captured (ok=False, with error_code / error_message / status_code) and the batch keeps going; pass stop_on_error=True to re-raise the first MailRelayError instead.

results = client.send_batch([
    {"from_addr": "noreply@acme.com", "to": "a@example.com", "subject": "Hi A", "text": "..."},
    {"from_addr": "noreply@acme.com", "to": "b@example.com", "subject": "Hi B", "text": "..."},
])
for r in results:
    print(r.index, r.ok, r.result.id if r.ok else r.error_code)

The async client accepts a concurrency (default 1 = sequential, matching the sync semantics); with concurrency > 1 up to that many sends run at once and every result is captured (so stop_on_error is not available in that mode):

results = await client.send_batch(messages, concurrency=10)

API keys (for non-SDK / direct-HTTP callers)

key = client.create_api_key("production")
print(key.key)        # bml_… — shown ONCE; store it now (only the prefix is kept)

# a direct caller authenticates with: Authorization: Bearer bml_…
# or build an SDK client that uses the key instead of HMAC signing:
other = MailRelayClient(MailRelaySettings(base_url=..., tenant_id=..., tenant_secret="",
                                          api_key=key.key))

for k in client.iter_api_keys():
    print(k.api_key_id, k.name, k.prefix, k.status)   # metadata only, never the key
client.revoke_api_key(key.api_key_id)                 # takes effect on the next call

Templates ({{var}} substitution)

tpl = client.create_template(
    name="welcome",
    subject="Welcome, {{name}}!",
    html="<p>Hi {{name}}, your code is {{code}}.</p>",   # {{var}} VALUES are HTML-escaped
    text="Hi {{name}}, your code is {{code}}.",
)
print(tpl.variables)   # ['name', 'code']

# send by template — render with vars (no inline subject/body needed):
client.send(from_addr="noreply@acme.com", to="customer@example.com",
            template_id=tpl.template_id, vars={"name": "Sam", "code": "12345"})

for t in client.iter_templates():     # keyset-paginated
    print(t.name, t.variables)
client.update_template(tpl.template_id, subject="Welcome aboard, {{name}}!")
client.delete_template(tpl.template_id)

Your sent-mail log

# Keyset-paginated. Walk one page at a time…
page = client.list_messages(status="failed", limit=50)   # status filter optional
for m in page.items:
    print(m.message_id, m.subject, m.status)
if page.next_cursor:
    page = client.list_messages(cursor=page.next_cursor)

# …or iterate every message, following cursors automatically:
for m in client.iter_messages(status="failed"):
    print(m.message_id, m.status)

msg = client.fetch_message(res.id)                 # full detail (incl. body)
print(msg.body_html)
for r in msg.recipients:                           # per-recipient delivery status
    print(r.address, r.status)                     # delivered | bounced | complained | ...

Domains, suppressions, inbound, webhooks

dom = client.add_domain("acme.com", inbound=True)     # returns DKIM/SPF/DMARC records to publish
client.verify_domain(dom.domain_id)                   # poll until status == "verified"

# Suppression list — addresses you'll never be sent to. Hard bounces and spam
# complaints are added automatically; add your own unsubscribes here too.
client.add_suppression("ex-customer@example.com", reason="unsubscribe")
for s in client.iter_suppressions():                  # keyset-paginated; auto-follows cursors
    print(s.address, s.reason, s.source)              # reason: manual|unsubscribe|complaint|hard_bounce
client.delete_suppression("ex-customer@example.com")  # re-allow after re-confirmation

for msg in client.iter_inbound():                     # the inbound inspector (keyset-paginated)
    full = client.fetch_inbound(msg.inbound_id)
    raw  = client.fetch_inbound_raw(msg.inbound_id)            # bytes (message/rfc822)
    pdf  = client.fetch_inbound_attachment(msg.inbound_id, 0)  # bytes

hook = client.create_webhook(url="https://api.example.com/api/v1/mail-callbacks",
                             events=["email.delivered", "email.bounced", "email.received"])
print(hook.secret)   # save it — verifies inbound webhook signatures (shown once)

for ep in client.iter_webhooks():            # endpoints (keyset-paginated)
    print(ep.endpoint_id, ep.url, ep.health, ep.status)

# the delivery log — debug why a webhook did/didn't fire:
for d in client.iter_webhook_deliveries(status="exhausted"):
    print(d.event_type, d.status, d.attempts, d.last_status)   # e.g. email.bounced exhausted 5 500
full = client.fetch_webhook_delivery(d.delivery_id)
print(full.payload)                          # the exact signed event body that was sent

Analytics

a = client.fetch_analytics(days=30)          # trailing window (1–365; out-of-range is clamped)
print(a.totals["email.sent"], a.totals["email.delivered"], a.totals["email.bounced"])
print(a.rates["delivery_rate"], a.rates["bounce_rate"])   # 0.0–1.0, divide-by-zero safe
for day in a.series:                         # dense: one row per calendar day in the window
    print(day["date"], day["email.sent"], day["email.delivered"])

# open/click metrics appear only when server-side tracking is enabled; until then they're
# absent (not a misleading 0%). Check before reading: a.tracked == {"opens": False, "clicks": False}
if a.tracked.get("opens"):
    print(a.rates["open_rate"])

Receive webhooks (FastAPI)

from fastapi import FastAPI
from bloonio_mail_relay_client import WebhookEventType, WebhookEvent
from bloonio_mail_relay_client.adapters.fastapi import BloonioMailAdapter

app = FastAPI()
_seen: set[str] = set()

async def on_received(ev: WebhookEvent) -> None:
    if ev.event_id in _seen:        # idempotency — see note below
        return
    _seen.add(ev.event_id)
    print("inbound email", ev.data["inbound_id"], ev.data["subject"])

# Reads BLOONIO_MAIL_* env (incl. BLOONIO_MAIL_WEBHOOK_SIGNING_SECRET) and mounts a
# signature-verified POST endpoint at BLOONIO_MAIL_CALLBACK_PATH (default
# /api/v1/mail-callbacks). A bad OR stale signature gets 401; handlers never run on them.
BloonioMailAdapter.from_env(app, handlers={WebhookEventType.EMAIL_RECEIVED: on_received})

Replay defense & idempotency. Each delivery is signed over {timestamp}.{sha256(body)}, and the adapter rejects deliveries whose timestamp is older than BLOONIO_MAIL_WEBHOOK_TOLERANCE_SECONDS (default 300s) — so a sniffed POST can't be replayed forever. Set it to None/large if you verify queue-delayed events. The window is not idempotency: the relay retries on non-2xx, so the same event can legitimately arrive more than once. Always dedupe handlers on ev.event_id (persist it; the snippet's in-memory set is illustrative only). Verifying manually instead of via the adapter? Call verify_webhook(secret, body, ts, sig) from core.hmac — it applies the same window.

Async variant: AsyncMailRelayClient — full method parity with the sync client (await + async with / aclose()), including send_batch(..., concurrency=N).

Receive webhooks (Django)

# urls.py
from django.urls import path
from bloonio_mail_relay_client import WebhookEventType
from bloonio_mail_relay_client.adapters.django import build_callback_view

def on_received(ev):            # sync or async — both work
    print("inbound email", ev.data["inbound_id"])

urlpatterns = [
    path(
        "api/v1/mail-callbacks",
        build_callback_view(handlers={WebhookEventType.EMAIL_RECEIVED: on_received}),
    ),
]

Same config and semantics as the FastAPI adapter: settings=None reads BLOONIO_MAIL_* env vars; a bad OR stale signature gets 401 (handlers never run), an invalid payload 400, and handler exceptions are logged but still return 200 so the relay doesn't retry. The replay-defense + event_id dedupe notes above apply unchanged. Sending needs no adapter — build MailRelayClient(MailRelaySettings()) anywhere (views, Celery tasks, management commands).

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

bloonio_mail_relay_client-0.13.1.tar.gz (34.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

bloonio_mail_relay_client-0.13.1-py3-none-any.whl (26.0 kB view details)

Uploaded Python 3

File details

Details for the file bloonio_mail_relay_client-0.13.1.tar.gz.

File metadata

File hashes

Hashes for bloonio_mail_relay_client-0.13.1.tar.gz
Algorithm Hash digest
SHA256 e42807b31db5de6f1471546c2514089bd0c6c535f33a9cf31367f3e63be40d00
MD5 c6ff4b05dbc05294133abe18993f6c0d
BLAKE2b-256 59560716a5e4080af75dcea5f99748536f5a82e467e4ba007a26779fa8692c36

See more details on using hashes here.

File details

Details for the file bloonio_mail_relay_client-0.13.1-py3-none-any.whl.

File metadata

File hashes

Hashes for bloonio_mail_relay_client-0.13.1-py3-none-any.whl
Algorithm Hash digest
SHA256 660c8298ddffe48c66e1c6cbaf6dce3282a1d3b58adb2fcf44e336bdf0af13a9
MD5 82fa9c4a10f704d4a3d7ee0f8de58ee3
BLAKE2b-256 d41031411985a9962e64437284251e4649eeeeb20123de77b5b8afa9498fe132

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page