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 -e .            # local dev (editable)
# or, in a consuming backend's requirements.in:
#   bloonio-mail-relay-client[fastapi] @ git+ssh://git@github.com/Bloonio/bloonio_mail_relay_client.git@v0.9.0

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).

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.12.1.tar.gz (31.7 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.12.1-py3-none-any.whl (23.0 kB view details)

Uploaded Python 3

File details

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

File metadata

File hashes

Hashes for bloonio_mail_relay_client-0.12.1.tar.gz
Algorithm Hash digest
SHA256 7885a4a4cc79d0a23ee67157a1d1348538b78ae1c4abe4f2278747b9ce493843
MD5 3b3ac3249d4f7d0507c5c6c493d23a1f
BLAKE2b-256 56fb96b96ceda7a4c3b0fc4b39a15c1ae97e814ee0959e2bb214cc9ca43caa0c

See more details on using hashes here.

File details

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

File metadata

File hashes

Hashes for bloonio_mail_relay_client-0.12.1-py3-none-any.whl
Algorithm Hash digest
SHA256 d06f185462468c0a95a8c614b80111e31da05eecc641b14a0499a36f28c5d5f0
MD5 5affc5da0acfe8de16ef01e7551e9daf
BLAKE2b-256 bdd2dcb1ab4d96c801bbd4fad54566405d92331422a47d296f80dbf7afc82885

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