Short answer: after a user import, send the bulk welcome email in bounded batches, let support own the template, and let the worker own pacing and retries. A batch is successful only when each accepted transactional email has an idempotency record; an HTTP 200 from a provider is not proof that the customer saw anything.
This is an architecture decision for a marketplace that imports sellers and sends a transactional welcome email while routing later contact-form submissions to the right support queue. The awkward part is ownership. Product wants editable copy, support wants queue-specific context, and the data pipeline wants a restartable job that cannot send the same greeting twice. Those are different boundaries, so I keep them separate.
The invariants and failure boundaries
The import creates a durable welcome_intent row per account. It contains a template revision, recipient address, locale, and a stable deduplication key such as welcome:{account_id}:{revision}. A worker claims pending rows, renders the revision it was assigned, and records the provider response before acknowledging the queue message. A crash between delivery and acknowledgement is therefore a duplicate risk, not a reason to pretend the operation is exactly once.
This boundary is easy to say and surprisingly easy to violate.
I treat four states as distinct: queued, accepted, permanently rejected, and retryable. A malformed address is permanent. A timeout is retryable. A provider acceptance response moves the row to accepted even though downstream delivery can still bounce. Bounce and complaint events belong to a separate event consumer; folding them into the import loop makes a slow feedback channel hold the whole batch hostage.
The support queue has its own invariant: a contact form must resolve to a queue using the submitted marketplace category and account state, never by parsing the welcome email. The template can link to the form, but it cannot become a hidden database.
What should a batch email worker do when rate limits and retries collide?
Use a token bucket per sending identity, with a smaller global ceiling than the provider advertises until measurements justify raising it. Batch size controls memory and transaction duration; it does not replace pacing. Keep the two knobs independent. A batch of 50 with a one-second refill behaves very differently from 50 parallel requests.
Here is the critical path in Python-like pseudocode. The transport is deliberately generic so the same policy can sit above an SMTP relay or an HTTP API.
import time
MAX_ATTEMPTS = 5
BASE_DELAY = 2.0
def send_one(intent, transport, limiter, store):
key = intent["dedupe_key"]
if store.exists(key):
return "already-recorded"
limiter.take() # token bucket enforces the configured rate
payload = render_template(intent["template_revision"], intent)
for attempt in range(1, MAX_ATTEMPTS + 1):
try:
result = transport.send(payload, idempotency_key=key)
store.record(key, result.message_id, intent["template_revision"])
return "accepted"
except TemporaryTransportError:
if attempt == MAX_ATTEMPTS:
store.defer(key, reason="retry-budget-exhausted")
return "deferred"
time.sleep(BASE_DELAY * (2 ** (attempt - 1)))
except PermanentRecipientError as error:
store.reject(key, reason=str(error))
return "rejected"
def render_template(revision, intent):
# Revision is selected by support, never by an untrusted form field.
return {
"to": intent["email"],
"template": revision,
"variables": {"display_name": intent["display_name"]},
}
The provider's idempotency feature, when available, narrows the duplicate window; the local unique key remains necessary because retries can happen before a request reaches the provider. Add jitter to backoff in production so several workers do not wake on the same second. I am not sure any fixed delay survives every provider policy, so the limiter should consume response headers and metrics rather than treating BASE_DELAY as a promise.
Consider a 50-row batch where row 17 times out after the remote server has accepted it. Retrying the whole batch creates 49 duplicate opportunities, while retrying only row 17 still risks a duplicate unless the dedupe key travels with the request. The safer sequence is to persist an attempt record, retry that one intent with the same key, and reconcile the provider event later. That extra write costs a little latency, but it gives an operator a concrete answer when an account owner asks why a message appears twice. It also means a worker restart is boring: rows 1–16 are already recorded, row 17 is either accepted or deferred, and rows 18–50 remain pending. Boring is the goal.
Keep this example in your runbook.
Who owns templates, queue routing, and audit data?
Template ownership is a change-control decision, not a UI preference. Support can publish a revision, but the import job stores that revision at enqueue time. Editing a template halfway through a 200,000-account import must not produce a mixed campaign whose wording cannot be reconstructed later.
The queue router reads normalized fields: issue_type, seller_tier, language, and fraud-review status. It writes a routing decision with a reason code. If a category is unknown, route to a visible triage queue and emit a metric; silently defaulting to “general” hides taxonomy drift.
Keep audit data append-only: intent created, attempt started, provider accepted, bounce received, and queue decision changed. Payload bodies may contain personal data, so retain hashes and template revision IDs where full content is unnecessary. Encryption, access controls, and a deletion process still apply; transactional classification does not exempt an email from privacy obligations.
Options and their trade-offs
| Design | Strength | Boundary | Choose it when |
|---|---|---|---|
| One worker, synchronous sends | Easy to trace | A slow provider blocks imports | Volume is tiny and a human watches runs |
| Queue plus token bucket | Restartable and observable | More state and operational tooling | Imports are recurring or bursty |
| Provider-managed campaign | Delivery tooling is rich | Template and recipient ownership move outside your system | Marketing owns the audience and transactional guarantees are irrelevant |
| Self-hosted relay | Control over data path | Deliverability reputation becomes your job | Compliance requires network-level control and you have mail expertise |
The catch is that a provider-managed campaign is unsuitable when support must prove which template revision was sent to a particular seller. Conversely, a self-hosted relay is a poor fit for a small team that cannot monitor reputation, bounces, and blocklists. Stick with the synchronous worker when the import is measured in dozens, not thousands, and the job can be retried manually.
If you evaluate hosted APIs, compare boundaries rather than slogans. Amazon SES exposes sending quotas and suppression controls; SendGrid separates dynamic templates from send requests; Mailgun exposes domain and event tooling. Those differences affect who owns state and evidence, not whether your worker still needs dedupe and backoff. None removes the need to read the current sender requirements and to test the exact account limits.
Verification before production
Run a dry import against a sink transport and assert that every account yields one intent key. Then kill a worker after the transport accepts a message but before store.record; the restart should converge to one recorded send when the transport honors the key, or flag the row for review when it does not. Test a 429, a socket timeout, a permanent rejection, and a late bounce separately. A single “retry works” test is not enough.
Observe queue age, attempts per accepted message, limiter wait time, rejection reason, bounce rate, and template revision distribution. Alert on a rise in unknown routing categories before customers notice that tickets are landing in the wrong team. Sample rendered content in a protected test mailbox, not production logs.
Email sender guidelines also make authentication, spam rate, and unsubscribe handling operational concerns, while SMS has its own encoding and segmentation limits if the same workflow later adds a text channel. Treat those as channel-specific policies behind the same intent and audit model.
Four rules are enough to keep the design honest: support publishes immutable revisions, the worker paces independently of batch size, every attempt has a stable key, and delivery evidence is separate from queue routing. The implementation can change; those invariants should not.
References
- https://support.google.com/a/answer/81126
- https://www.twilio.com/docs/glossary/what-sms-character-limit
- https://www.rfc-editor.org/rfc/rfc5321
- https://docs.aws.amazon.com/ses/latest/dg/manage-sending-quotas.html
- https://www.twilio.com/docs/sendgrid/ui/sending-email/how-to-send-an-email-with-dynamic-templates
- https://documentation.mailgun.com/docs/mailgun/user-manual/events/events-overview
Top comments (0)