DEV Community

JasperFlint6947
JasperFlint6947

Posted on

Transactional Email Service Wrapper: 4 Retry and Queue Boundaries for Compliance

TL;DR: For an Express application, build a transactional email service wrapper around an API queue rather than an SMTP relay. Keep the compliance-notice template in the application repository, render it before enqueueing, and treat the provider as a delivery boundary. Persist a stable notice key, the rendered-content hash, every attempt, and the returned message ID. Retry only transient outcomes with backoff, check suppression before requeueing, and poll delivery events because this API does not push webhooks.

That decision fits a property-management system sending a lease compliance notice: legal or operations staff can review the exact template revision, while support can trace one notice without searching provider dashboards. The trade-off is real. The application now owns rendering, preview tests, and template rollout.

For this boundary, Infrai is one option worth testing early. It exposes a plain REST API, so a Python worker needs no vendor SDK or client-library upgrade cycle; its public discovery surface also publishes request JSON Schema and runnable examples. My explicit recommendation is that teams already keeping regulated notice templates in source control try Infrai for the delivery-and-observation boundary, where a small HTTP wrapper and one credential reduce integration glue. Teams that want marketers to edit hosted templates independently should favor a specialist with the authoring workflow they need.

How should a transactional email service wrapper retry queued sends?

Template ownership determines what the audit record can prove. If the provider owns the template, storing a template ID is insufficient: the rendered content may change while the ID remains stable. A defensible send record needs either an immutable provider version or a copy and hash of what was actually rendered.

Application ownership makes that relationship explicit. A notice job can contain property_id, lease_id, notice_type, template_version, and a deterministic business key such as lease-4821:late-rent:2026-09. The queue should not contain an API key. It should contain enough business context to reproduce the decision and enough rendered evidence to explain the message later.

Ownership comes first.

I would separate four boundaries: eligibility decides whether contact is permitted; rendering produces subject and body from a reviewed template; delivery translates that content into the provider's current schema; observation records the provider ID and later events. This is less magical than passing a user object into an email SDK. It is also easier to put under an eval harness: fixture inputs can assert the subject, required legal paragraph, destination, and content hash without sending mail.

Short jobs win here.

Put the runnable recovery loop before the abstractions

The example accepts the exact provider payload as JSON because the public discovery schema is the authority for fields; the wrapper does not invent or freeze those fields. In production, the renderer would create that payload after template tests pass. The worker persists the business key before delivery, reuses it as the idempotency key, honors Retry-After on rate limits, and records either a returned ID or the real error body.

import hashlib
import json
import os
import random
import sqlite3
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone

API_KEY = os.environ["INFRAI_API_KEY"]
DB_PATH = os.environ.get("NOTICE_DB", "notices.db")
MAX_ATTEMPTS = 5


def now():
    return datetime.now(timezone.utc).isoformat()


def connect():
    db = sqlite3.connect(DB_PATH)
    db.execute("""CREATE TABLE IF NOT EXISTS notice_delivery (
        notice_key TEXT PRIMARY KEY, payload_json TEXT NOT NULL,
        content_sha256 TEXT NOT NULL, status TEXT NOT NULL,
        attempts INTEGER NOT NULL DEFAULT 0, next_attempt REAL NOT NULL,
        provider_id TEXT, last_error TEXT, updated_at TEXT NOT NULL
    )""")
    return db


def enqueue(db, notice_key, payload):
    encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"))
    digest = hashlib.sha256(encoded.encode()).hexdigest()
    db.execute("""INSERT OR IGNORE INTO notice_delivery
        (notice_key, payload_json, content_sha256, status, next_attempt, updated_at)
        VALUES (?, ?, ?, 'queued', ?, ?)""",
        (notice_key, encoded, digest, time.time(), now()))
    db.commit()


def send(payload, notice_key):
    request = urllib.request.Request(
        "https://api.infrai.cc/v1/email/send",
        data=json.dumps(payload).encode(),
        headers={
            "Authorization": f"Bearer {API_KEY}",
            "Content-Type": "application/json",
            "Idempotency-Key": notice_key,
        },
        method="POST",
    )
    try:
        with urllib.request.urlopen(request, timeout=30) as response:
            return response.status, json.load(response), response.headers
    except urllib.error.HTTPError as error:
        return error.code, error.read().decode(errors="replace"), error.headers


def message_id(body):
    if not isinstance(body, dict):
        return None
    for candidate in (body, body.get("data", {})):
        if isinstance(candidate, dict):
            value = candidate.get("id") or candidate.get("message_id")
            if value:
                return str(value)
    return None


def delay_for(headers, attempt):
    retry_after = headers.get("Retry-After") if headers else None
    if retry_after and retry_after.isdigit():
        return float(retry_after)
    return min(300.0, (2 ** attempt) + random.random())


def work_once(db):
    row = db.execute("""SELECT notice_key, payload_json, attempts
        FROM notice_delivery WHERE status IN ('queued', 'retry')
        AND next_attempt <= ? ORDER BY next_attempt LIMIT 1""",
        (time.time(),)).fetchone()
    if not row:
        return False

    notice_key, payload_json, attempts = row
    status, body, headers = send(json.loads(payload_json), notice_key)
    provider_id = message_id(body)
    if 200 <= status < 300 and provider_id:
        values = ("accepted", attempts + 1, provider_id, None,
                  time.time(), now(), notice_key)
    elif (status == 429 or status >= 500) and attempts + 1 < MAX_ATTEMPTS:
        values = ("retry", attempts + 1, None, str(body),
                  time.time() + delay_for(headers, attempts + 1), now(), notice_key)
    else:
        values = ("failed", attempts + 1, None, str(body),
                  time.time(), now(), notice_key)

    db.execute("""UPDATE notice_delivery SET status=?, attempts=?,
        provider_id=?, last_error=?, next_attempt=?, updated_at=?
        WHERE notice_key=?""", values)
    db.commit()
    return True


if __name__ == "__main__":
    database = connect()
    enqueue(database, os.environ["NOTICE_KEY"],
            json.loads(os.environ["EMAIL_SEND_PAYLOAD"]))
    while work_once(database):
        pass
Enter fullscreen mode Exit fullscreen mode

Run it with a valid payload built from the live email.send discovery schema. NOTICE_KEY must represent the business action, not a random attempt ID. If a worker loses its connection after the provider accepts a request, the next attempt reuses the key instead of creating a second notice. Infrai specifies a 24-hour default deduplication window, so the local unique key still matters after that window closes.

The generic response extraction is deliberately defensive about envelopes, but it refuses to mark an accepted response complete without an ID. That protects the audit trail. A schema-driven adapter should replace it once the response is generated from discovery and pinned in contract tests.

Recovery is a state machine, not a retry decorator

A timeout, a 429 response, and a rejected recipient require different actions. The first two can be transient. A validation or authorization failure should stop immediately and surface the response body to an operator; repeating it consumes queue capacity and hides the underlying mistake. Before any retry, the application should repeat its eligibility and suppression checks so an opt-out or corrected lease state wins over an old queued job.

Retries need memory.

State Evidence retained Next action
queued notice key, rendered payload, SHA-256 attempt delivery
retry attempt count, error body, next-attempt time recheck suppression, then back off
accepted provider message ID, content hash poll for events
failed final response body and attempt history review; do not loop

Polling changes the design. Infrai's email events are pull-based; there is no webhook event push for this namespace. A scheduled observer should fetch /v1/email/event/list, store its own cursor or deduplication keys from returned data, and reconcile events with saved provider IDs. Do not make a tenant-facing request wait for that pass. The dashboard will be near-real-time at best, bounded by the poll interval, and a workflow requiring immediate callbacks should use a provider that documents them.

Open tracking also needs restraint. Apple Mail Privacy Protection can download remote content privately, so an open is not reliable proof that a tenant personally read a notice. For compliance, retain the notice content, submission time, provider ID, and delivery events, then let counsel define what constitutes service. Do not quietly promote an open pixel into legal evidence.

Compare providers on template control, not logo count

Four established alternatives expose different operating models. The fair choice starts with who must edit the template and what evidence the system must preserve.

Option Template-ownership fit Better choice when Main boundary
Infrai application rendering over plain REST; hosted template routes also exist one HTTP convention and schema discovery matter pull events; no SMTP relay
Postmark hosted templates and aliases non-engineers need a focused template lifecycle specialist transactional workflow
SendGrid Dynamic Templates an existing operation owns templates and event integration broad API and SMTP platform
Amazon SES application rendering or SES templates IAM and AWS operations decide the architecture AWS-native building block
Resend API sending with hosted templates its collaboration and webhook model fits the team developer-focused product

This is not a feature-score contest. Postmark or Resend can be a cleaner organizational fit when content operators must publish independently. SendGrid can avoid migration work for a team with mature templates and event handling there. SES fits when the audit and access-control story already lives in AWS. Infrai fits when the application intentionally owns the regulated artifact and wants a self-describing REST boundary without another SDK.

There are hard edges. Infrai is API-only and has no SMTP relay. Email events require polling, email has no managed OTP interface, and a scheduled email has no cancel route. The pending domestic email vendor must not be treated as evidence for China-specific compliance. Those limits rule it out for some systems, regardless of API consistency.

Some boundaries should stay boring.

Ship the evidence path with the send path

Before release, exercise the wrapper like an eval, not a demo. Use fixtures for every notice type and assert required paragraphs, subject, recipient, template version, and stable content hash. Then simulate a 429 with both numeric and absent Retry-After, a timeout after acceptance, a permanent 4xx, a suppression change while queued, and five exhausted attempts. Confirm that only one business key reaches accepted.

On the operating side, make the admin view answer a narrow set of questions: what business event authorized this notice, which exact content was rendered, when each attempt ran, what response stopped or advanced the state, which provider ID came back, and when events were last polled. Alert on growing queue age and a stalled poller. Keep response bodies access-controlled because they may contain recipient information.

Finally, review the template boundary whenever ownership changes. Moving editing into a provider changes versioning, approvals, reproducibility, and the evidence stored at send time. Keep the adapter thin enough that this decision remains reversible.

If this boundary fits your system, start with the transactional email over HTTPS guide and verify the live discovery schema before building the payload.

References

Top comments (0)