DEV Community

CelesteRaine1783
CelesteRaine1783

Posted on

Bulk Batch Sending Transactional Onboarding Emails for Property Compliance

The decisive trade-off is template ownership: a property-management SaaS should own the versioned content and evidence model for a compliance notice, while a delivery adapter should own transport-specific submission. That division makes bulk onboarding email replaceable without making the audit trail vague. TL;DR: freeze one rendered message per recipient, assign an idempotency key before sending, record submission and later delivery events separately, and never treat a successful batch request as proof of delivery.

This applies to a welcome campaign that contains a required notice as well as to a purely transactional message. “Welcome” describes the recipient experience; it does not relax the recordkeeping requirement. EU and US recipients may need different policy-approved text, but geography should select a reviewed template version, not trigger improvised branching inside a provider dashboard.

Why should the application own the compliance template?

The system that decides who must receive a notice also needs to answer what that person was sent. If the only authoritative template lives in a delivery service, an ordinary copy edit can silently change the meaning of a retry, and a later export may prove that a template name was invoked without proving which bytes were rendered. That is an ownership failure, not an email API problem.

Keep the durable template in the application's release process. Give it an immutable identifier such as lease-welcome-eu:7, store the variables used for each rendering, and calculate a digest of the final subject and body. The delivery provider may still hold a compiled or cached copy, but that copy is a deployment artifact derived from the application-owned source. It is not the record of truth.

Do not store only the digest. A hash can show that two byte sequences match; it cannot reconstruct the notice during an audit. Retain the rendered content, template version, locale, policy region, recipient identity as understood at send time, and the digest under a retention policy reviewed for the application's obligations. Access to that evidence should be narrower than access to campaign analytics because it can contain tenant and resident data.

The split is easier to see in a trade-off table:

Template model Change control Evidence quality Migration cost Main failure mode
Application-owned source and rendering Same review path as application code Can preserve exact rendered content and inputs Lower, because transport is behind an adapter Deploying content and adapter configuration out of sync
Provider-owned template Separate dashboard workflow Depends on retaining provider template versions and exports Higher, because identifiers and rendering behavior travel with the provider Mutable template name points to revised content during retry
Split rendering without an immutable release manifest Two control planes Ambiguous unless both versions are captured Uncertain Nobody can reproduce the message that was submitted

The first model creates more release work. I would accept it here because the hard question is not “did the loop call an API?” but “can an operator reproduce the notice without trusting a mutable external control plane?”

How should a small SaaS batch and send transactional onboarding emails?

Start with one ledger row per logical recipient and notice, not one row per HTTP request. Batches are temporary transport groupings. Audit identities must survive a change from batches of 100 to batches of 20, a worker restart, or a new transport implementation.

Identity comes first.

The following Python sketch prepares immutable send commands. The batch size of 100 is an application policy in this example, not a claim about a provider limit. The caller persists each command before handing it to a transport adapter.

from __future__ import annotations

from dataclasses import asdict, dataclass
from hashlib import sha256
from itertools import islice
import json
from typing import Iterable, Iterator


@dataclass(frozen=True)
class NoticeCommand:
    notice_id: str
    tenant_id: str
    recipient: str
    template_version: str
    policy_region: str
    subject: str
    body: str
    content_digest: str
    idempotency_key: str


def stable_digest(subject: str, body: str) -> str:
    payload = json.dumps(
        {"subject": subject, "body": body},
        ensure_ascii=False,
        sort_keys=True,
        separators=(",", ":"),
    ).encode("utf-8")
    return sha256(payload).hexdigest()


def make_command(resident: dict[str, str]) -> NoticeCommand:
    template_version = f"lease-welcome-{resident['policy_region'].lower()}:7"
    subject = "Your tenancy onboarding notice"
    body = (
        f"Hello {resident['display_name']},\n\n"
        f"Your property reference is {resident['property_reference']}.\n"
        "Review the attached onboarding notice in your tenant portal."
    )
    notice_id = resident["notice_id"]
    return NoticeCommand(
        notice_id=notice_id,
        tenant_id=resident["tenant_id"],
        recipient=resident["email"],
        template_version=template_version,
        policy_region=resident["policy_region"],
        subject=subject,
        body=body,
        content_digest=stable_digest(subject, body),
        idempotency_key=f"onboarding-notice:{notice_id}",
    )


def chunks(items: Iterable[NoticeCommand], size: int = 100) -> Iterator[list[NoticeCommand]]:
    iterator = iter(items)
    while batch := list(islice(iterator, size)):
        yield batch


commands = [make_command(resident) for resident in residents]
ledger.insert_pending([asdict(command) for command in commands])

for batch in chunks(commands):
    queue.publish([asdict(command) for command in batch])
Enter fullscreen mode Exit fullscreen mode

The example deliberately does not call a commercial endpoint. A production adapter can translate each command into a particular batch-send request, but it must return a result for each recipient. If the transport reports only one identifier for an entire batch, retain that batch identifier as correlation data while keeping recipient-level ledger rows.

There is another boundary worth defending: campaign selection and delivery should be separate jobs. Selection freezes the eligible audience against a named onboarding run; delivery consumes already-created commands. Otherwise, a retry can rerun a database query after a resident's status changes and produce a different audience under the same campaign label.

Failure is a state machine, not a Boolean

A request accepted by a transport means less than many dashboards imply. It does not establish inbox placement, human receipt, or legal sufficiency. The ledger should therefore distinguish at least pending, submitted, delivered, temporarily_failed, permanently_failed, and suppressed, with raw event references retained alongside normalized states. Whether “delivered” is enough for a particular notice is a policy and legal decision outside the mail adapter.

Duplicates are the first operational trap. A worker can time out after the remote system accepted a message but before the acknowledgement reached the worker. Retrying blindly may send the same notice twice; refusing all retries may leave it unsent. Use the preallocated idempotency key at the adapter boundary when supported, and always enforce uniqueness in the local ledger. If remote idempotency is unavailable or has an insufficient lifetime, move the row into an uncertain state and reconcile it rather than pretending the ambiguity disappeared.

Partial acceptance is the second trap. One malformed address must not erase successful submissions for the other 99 commands, and one batch-level success must not mark all 100 as submitted. Persist per-recipient outcomes in the same transaction that records the attempt. Keep error categories stable even if raw provider codes change, because alerting and retry policy should depend on your categories.

Ambiguity is a state.

Retries need a ceiling. For example, an operator might approve six attempts with increasing delays for temporary transport failures, while permanent address failures receive no automatic retry. Those values are operational choices to test under the actual transport contract; they are not universal constants. Add randomized delay, cap concurrent work per tenant, and make a campaign pausable. A global queue that lets one 50,000-recipient onboarding import starve routine notices has preserved throughput while breaking the service objective that matters.

Bad recipient data is not a transport failure. Validate required fields before rendering, reject missing policy-region mappings, and quarantine a row whose template version cannot be resolved. Dead-letter records must remain inspectable and replayable by notice ID after correction. Never repair them by issuing an ad hoc message from an operator's mailbox; that creates a second, unaudited delivery path.

Observe evidence, not campaign vanity metrics

Three counters provide a useful starting shape: commands created, recipient submissions accepted, and terminal outcomes observed. They should reconcile by onboarding run, tenant, template version, and policy region. Alert on old pending rows and old submitted rows, not merely on a low aggregate success percentage. Age exposes stuck work that a healthy daily average can conceal.

Keep correlation identifiers in structured logs, but keep rendered bodies and addresses out of routine log streams. The evidence store already has a controlled copy. Logs need notice_id, attempt_id, template_version, normalized outcome, and transport correlation ID; observability does not require duplicating the notice into every indexing system.

The deployment test matrix should include a duplicate command, a mixed-result batch, an acknowledgement timeout, an out-of-order delivery event, an event repeated twice, and a template version removed too early. A contract test can feed recorded adapter-shaped responses into the state machine without contacting an external service. Then a small canary run verifies credentials and event ingestion before a large onboarding import is released.

Keep channel semantics separate too. If SMS becomes a fallback, it needs its own consent and messaging-policy review, content constraints, evidence fields, and delivery adapter; an email failure should not automatically authorize a text message. CTIA's messaging interoperability and compliance material is a relevant primary reference for the SMS side of that review [2].

Compare transports at the boundary you control

Choosing a transport after the ledger exists changes the evaluation. Compare candidates by whether their interfaces preserve recipient-level identity, expose asynchronous outcome events, document retry and idempotency behavior, support the required sending regions, and allow export of evidence needed by your retention process. Verify every limit against current primary documentation during procurement and encode accepted limits in adapter configuration rather than prose scattered through the application.

Price belongs in capacity planning, but it is not the architectural center. Model request volume, event volume, evidence storage, support load, and migration effort together. A low send charge does not compensate for a control plane that cannot preserve the ownership boundary.

Commercial documentation can still be useful as interface evidence. For example, the Resend introduction is one public description of an email API [1], but a single provider's documentation cannot establish the behavior of another provider or settle the application's compliance design. Run the same adapter contract suite against every implementation considered.

Roll out without changing the meaning of a notice

Begin by assigning immutable versions to the current templates and writing new notices to the ledger without sending from it. Compare those shadow records with the existing system for one controlled onboarding run. Next, enable delivery for a small, explicitly selected cohort, reconcile every ledger row to an outcome, and test the operator procedure for uncertain and permanently failed sends.

Only then increase batch concurrency. Keep the old path available for rollback, but prevent both paths from consuming the same notice ID. A migration is complete when the evidence export, replay controls, retention process, and adapter removal procedure work, not when the first batch returns success.

That is the durable design rule: own the notice where its meaning is governed, and rent the transport behind a narrow, tested boundary.

Sources

References:

  1. Resend, “Introduction”: https://resend.com/docs/introduction
  2. CTIA, “Messaging Interoperability and SMS/MMS”: https://www.ctia.org/the-wireless-industry/industry-commitments/messaging-interoperability-sms-mms

Top comments (0)