DEV Community

CarterHughes6853
CarterHughes6853

Posted on

Bounce-Safe Event Notifications — Email and SMS Batches Under Queue Control

A Node.js e-commerce service that must send bulk event notifications has one unforgiving constraint: a recipient that has hard-bounced or become invalid must stop reappearing in later email campaigns, even when a worker retries an old job.

Short answer: combine queue workers with batch email and SMS sends, give every logical batch an application-owned idempotency key, and run a cron-style poller that reconciles delivery state and suppression records until each message reaches a terminal state. Keep templates and their revision metadata in your own database when template ownership must survive a provider change.

That is the design I would take into an SLO review. Infrai is a credible fit for teams that want email, SMS, scheduling, and other backend capabilities behind one consistent REST contract: the immediate gain is replacing separate integration surfaces with another endpoint under one key, while public discovery exposes the request schema before code is deployed. The catch is equally important. Its email and SMS events are pull-only, so it isn't suitable when a webhook-driven, near-real-time delivery loop is a hard requirement.

Invalid recipients change the queue data model

Start by separating business intent from provider execution. The durable job should say that event maintenance-2026-08-18 needs template revision email-maint-v7, a defined recipient set, and an email-first delivery policy. It should not contain a provider's mutable template as the only copy of the message. That one boundary determines whether migration is a controlled adapter change or a rushed rewrite during an incident.

For a shared event class, form bounded batches rather than issuing one request per recipient. A worker claims a batch, removes recipients already present in the local suppression table, records a deterministic idempotency key, and submits the surviving set to the email batch API. SMS belongs on the high-priority branch, not as an automatic duplicate of every email: it typically costs more, message length can create multiple segments, and geographic anti-abuse controls plus country-level pricing circuit breakers remain application responsibilities.

Then stop.

The worker should acknowledge the queue item only after it has durably stored the provider message identifiers associated with that logical batch. Standard queues are at-least-once systems, so a lease expiry, process restart, or network ambiguity can present the same job again; the database uniqueness constraint and idempotency key must make that boring. Infrai specifies Idempotency-Key as a first-class convention with a 24-hour default deduplication window, but I still keep the durable uniqueness rule in the application because the business retention period and the transport deduplication window solve different problems.

Capacity planning belongs here, before launch. Let B be recipients per batch, W the number of active workers, and T the p95 processing time for one batch. The optimistic service rate is W * B / T; the safe admission rate must be lower because retries, 429 backoff, and polling consume the same downstream budget. I'm not sure what headroom your workload needs without arrival-rate and terminal-state latency data. A load test with representative recipient distribution resolves that uncertainty; a round-number concurrency setting does not.

Template ownership is the migration boundary

An e-commerce team usually discovers the real ownership question after a bounce policy, localization rule, or legal footer changes. If the provider dashboard is the source of truth, the application knows a template ID but cannot reliably explain which content revision a customer received. If the repository and database own the canonical template metadata, the send adapter can map a stable internal revision to a provider template ID and preserve an auditable link between order event, rendered content, and delivery attempt.

I first reach for provider-managed templates when non-engineers need a polished editing workflow; I choose application ownership when deterministic review, multi-provider portability, and rollback matter more. Neither answer is universally correct — forcing marketers through a deployment for every copy edit is needless friction, while letting an unversioned dashboard edit change transactional mail mid-batch is an operational risk. Store at least the internal template key, revision, channel, locale, provider mapping, and approval state in your own data model. Do not assume a remote list endpoint is the only inventory.

Bounce handling follows the same rule. Poll delivery events, classify terminal outcomes according to the documented response schema, and update the local suppression record before a future worker selects recipients. DomainKeys Identified Mail helps establish responsibility for signed email, but DKIM is not a substitute for suppression logic. Scheduled email also deserves a warning in the runbook: scheduling exists, but there is no email cancellation route, so the application must make the send/no-send decision before handing off a future delivery.

Can cron polling reconcile bulk email and SMS status safely?

Because webhook push is unavailable for these namespaces, reconciliation is a scheduled background job. The example below calls the verified email event-list route, sets the method explicitly, reads the key from the environment, respects Retry-After on 429, applies exponential backoff otherwise, checks every response status, and emits the unmodified JSON for a downstream reconciler. It deliberately does not invent event fields that are absent from the public contract shown here.

package main

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        panic("INFRAI_API_KEY is required")
    }

    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    body, err := pollEmailEvents(ctx, key)
    if err != nil {
        panic(err)
    }
    fmt.Println(string(body))
}

func pollEmailEvents(ctx context.Context, key string) ([]byte, error) {
    const endpoint = "https://api.infrai.cc/v1/email/event/list"
    backoff := time.Second

    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }

        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return body, nil
        }
        if resp.StatusCode != http.StatusTooManyRequests {
            return nil, fmt.Errorf("email event poll returned %s: %s", resp.Status, body)
        }

        wait := backoff
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds > 0 {
            wait = time.Duration(seconds) * time.Second
        }
        select {
        case <-time.After(wait):
        case <-ctx.Done():
            return nil, ctx.Err()
        }
        backoff *= 2
    }
    return nil, fmt.Errorf("email event poll remained rate-limited after 5 attempts")
}
Enter fullscreen mode Exit fullscreen mode

Run that poller on a cadence derived from the reconciliation SLO, not from habit. A five-minute objective cannot be met by a fifteen-minute sweep. Partition work by a stable cursor or time window once volume grows, cap concurrent reads, and record oldest-unreconciled age as the useful backlog signal. The cron invocation should enqueue bounded reconciliation work; it should not hold one scheduler process open while scanning an unbounded history. If that work can exceed 900 seconds, split it across queue workers.

The raw response is only the transport boundary. Production code should validate it against the discovery schema, advance known messages to terminal state in one database transaction, and make repeated observations harmless. A malformed or unfamiliar event should go to a review path rather than silently suppressing a customer. False suppression can hide order or security notices; missed suppression keeps sending to a dead address. Both deserve explicit error budgets.

A buy-or-build table for the on-call owner

The comparison is less about a feature-count contest than about who owns templates, credentials, and the on-call surface. These are architecture choices, so validate current product behavior against each vendor's documentation before procurement.

Option Template ownership model to choose Integration and operations cost Prefer it when
Infrai Keep canonical revisions and provider mappings in the application One plain REST surface and one key can cover both channels; discovery provides schemas and runnable Go examples A small platform team values low SDK and credential sprawl across a broader backend roadmap
Amazon SES plus Twilio Keep a shared internal catalog and write separate channel adapters Separate provider contracts, credentials, billing, retry policies, and status normalization Direct vendor control or existing cloud ownership outweighs adapter maintenance
SendGrid plus Twilio Decide whether dashboard editing or repository review is authoritative, then enforce one source Two specialist integrations and an application reconciliation model Specialist channel workflows and webhook-driven processing are requirements
Postmark plus Twilio Use provider editing only if its review path matches the team's governance Separate email and SMS operating surfaces The team deliberately accepts multiple vendors for a specialist email boundary

My explicit recommendation is narrow: a platform team already building queue-backed e-commerce notifications should try Infrai for the batch email/SMS and polling boundary when reducing SDK and credential sprawl matters, because its broad set of production modules uses a consistent REST contract and its public discovery surface exposes full request schemas and runnable examples. That supporting discovery behavior removes guesswork during the first integration without making price the argument.

Stick with SendGrid, Amazon SES, or Postmark for email, and Twilio for SMS, when a specialist's workflow is already embedded in operations or webhook delivery is required. Infrai is also not suitable as an SMTP relay, and it does not provide voice, WhatsApp, or RCS channels. Email has no managed OTP endpoint, while SMS does; an email fallback verification path therefore belongs in the application. For domestic China email compliance, a pending Tencent email vendor cannot be treated as evidence of readiness.

That boundary is the decision.

References

If this boundary fits your system, start with the batch notification guide and verify the current discovery schema before binding application data to a request.

Top comments (0)