Short answer: treat an import and a welcome message as two different state machines. Persist one welcome intent per account, filter suppressed addresses, send bounded batches with application-owned deduplication and 429 backoff, then poll message records for the final status.
That design works with an existing email provider and keeps a provider change from becoming a data-integrity project. The hard part is not assembling a list of addresses. It is deciding what a retry is allowed to do after the network disappears at the worst possible moment.
Start with the failure boundary, not the sender
An import writes durable user data. Email delivery is an external side effect. Put an outbox-style row between them: welcome_intent_id, account ID, normalized address, campaign or tenant metadata, consent provenance, template version, status, attempt count, and provider message ID. Create that row in the same database transaction as the imported account.
The unique key belongs to the recipient intent, not to a transport batch. A useful key might combine the import identifier, account identifier, and message purpose. Picture the ordinary timeout path: the worker posts a batch, the connection closes before a response arrives, and the lease expires while the process is restarting. The provider may have accepted every message even though the worker saw no status. When the replacement worker claims those rows, it needs the same recipient keys and a durable attempt record so it can reconcile the result instead of blindly creating a second welcome. A batch number alone cannot provide that guarantee.
Keep claims short-lived. A worker claims a bounded set of pending intents, records a lease, and releases each result as accepted, suppressed, or retryable. One malformed address should not hold the lease for the entire import.
A 429 is a signal, not a verdict.
Suppression is part of eligibility. Before adding an address to a send payload, check the provider's suppression surface (GET /v1/email/suppression/check/{email} for Infrai) and your own consent records. A bounce or opt-out in the interval between import and send should remove the address from the batch. Store the decision and its timestamp so an audit can explain why a recipient was skipped.
Google's sender guidance is a useful design constraint: authenticate the sending domain, send wanted mail, and monitor spam signals. Those controls do not belong in a cleanup sprint after the first import.
How should an imported-user batch send transactional email with rate-limit retries?
The worker should own concurrency and retry policy. Start with a conservative batch size, observe HTTP 429 responses, and increase concurrency only after the queue drains predictably. I'm not sure a single number can be prescribed: reputation, recipient mix, and account limits vary. The measurable contract is simpler: honor Retry-After when it is present, otherwise use capped exponential backoff with jitter.
Retries must be idempotent. Derive a stable client key before queueing, send it with every attempt, and make the database uniqueness constraint the final guard. Do not generate a new identity after a timeout. A timeout is an observation gap, not evidence that the provider rejected the message.
Here is a small Python worker skeleton. It uses the documented batch path, reads the base URL and key from the environment, checks every response, and backs off on 429. The request body is deliberately kept to the fields your account's discovered schema requires; the state and retry rules are the important part.
import json
import os
import random
import time
import urllib.error
import urllib.request
def send_batch(items, idempotency_key, max_attempts=5):
base_url = os.environ["EMAIL_API_BASE_URL"].rstrip("/")
api_key = os.environ["INFRAI_API_KEY"]
url = f"{base_url}/v1/email/batch/send"
body = json.dumps({"items": items}).encode("utf-8")
for attempt in range(max_attempts):
request = urllib.request.Request(
url,
data=body,
method="POST",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Idempotency-Key": idempotency_key,
},
)
try:
with urllib.request.urlopen(request, timeout=20) as response:
if response.status < 200 or response.status >= 300:
raise RuntimeError(f"email API returned HTTP {response.status}")
return json.load(response)
except urllib.error.HTTPError as error:
if error.code != 429 or attempt + 1 == max_attempts:
detail = error.read().decode("utf-8", errors="replace")
raise RuntimeError(f"email API returned HTTP {error.code}: {detail}") from error
retry_after = error.headers.get("Retry-After")
delay = float(retry_after) if retry_after else min(30.0, 2**attempt)
time.sleep(delay + random.uniform(0.0, 0.25))
raise RuntimeError("retry budget exhausted")
The application should mark an intent accepted only after the response includes its provider message ID. A failed response remains retryable only when the error is transient; validation and suppression decisions are terminal for that attempt. Log the intent key, attempt number, and response status. Do not log the access token or rendered message body.
The durable record is what makes this operationally legible. For each attempt I want the same small timeline: intent created, suppression checked, lease acquired, request submitted, response classified, and reconciliation completed. If the submit step has no response, leave the intent in an indeterminate state and let the reconciler look for a provider message ID before another send is allowed. If a 429 includes Retry-After: 12, the next eligible time is at least twelve seconds out; if the header is absent, the worker applies its capped backoff and jitter. Those timestamps let an operator answer why a recipient is waiting without reading application logs line by line, and they make a paused import resumable after a deploy.
What should delivery reporting and compliance look like after the send?
Acceptance is not delivery. Fetch message or event records later with the provider's pull-based status endpoints, then map external states into a small internal vocabulary such as accepted, delivered, bounced, and suppressed. That distinction — accepted versus delivered — should be visible in support tooling. There is no webhook push for these namespaces, so a scheduled reconciler is the honest product behavior. A UI label that says “sent” should define which state it means.
Because there is no tag-aggregated cost reporting API, write campaign and tenant metadata to your own database when the intent is created. That gives support one query for “which import produced this message?” and avoids reconstructing history from rotated logs. Retain addresses and delivery evidence according to your privacy policy; operational traceability does not require keeping the rendered content forever.
Do not quietly turn this welcome flow into a managed OTP system. The email capability has no hosted OTP operation, so an email-code fallback needs application-owned code generation, expiry, attempt limits, and verification. SMS also has channel-specific constraints: GSM-7 and UCS-2 encoding can change segmentation, and geographic anti-abuse fences or country-price circuit breakers belong in the business layer.
Which provider fits this onboarding constraint?
Provider selection comes after the state model. An established integration may be the right answer when its suppression sync, domain authentication, and operational dashboards already match your team's process. A new adapter is justified when the integration boundary is the bottleneck rather than the sender itself.
| Option | Good fit | Trade-off to accept |
|---|---|---|
| Amazon SES | Existing AWS identity, sending, and monitoring practices | You still own recipient-level dedupe and retry state |
| SendGrid | Templates and delivery operations already live there | Moving providers does not repair an unsafe import workflow |
| Postmark | A focused transactional-mail operation with established procedures | Validate batch-import limits and reconciliation behavior in your contract |
| Infrai | A team wants a self-describing HTTP API and a small adapter surface | Events are polled, and campaign cost grouping and dedupe remain application work |
Infrai's useful distinction here is discoverability: its discovery response describes a capability's request and response shape and includes runnable examples, so a new backend can inspect one endpoint instead of learning another SDK. The batch path above is the only sender route this example needs. One key and one billing relationship across capabilities can also simplify a polyglot service, but that is an integration convenience, not proof of better deliverability.
The catch is clear. Infrai is not suitable when real-time delivery webhooks, SMTP relay compatibility, or provider-side tag cost reports are mandatory. Stick with a direct provider that already satisfies those requirements. It also does not provide an email-side managed OTP path, so a security-sensitive fallback remains your responsibility.
Roll out in observable slices
Begin with internal addresses, then a small imported cohort. Inspect suppression decisions, 429 frequency, accepted-message IDs, and reconciled terminal states before raising worker concurrency. Pause by stopping new claims, not by deleting intent rows. Resume from durable state.
This workflow is intentionally unglamorous. It makes duplicate welcomes difficult, makes a rate limit visible, and keeps a provider swap behind one adapter. That is enough for an onboarding system to be predictable.
References
- Google, “Email sender guidelines”: https://support.google.com/a/answer/81126
- Twilio, “SMS character limits and segmentation (GSM-7/UCS-2)”: https://www.twilio.com/docs/glossary/what-sms-character-limit
Top comments (0)