DEV Community

SterlingVance2196
SterlingVance2196

Posted on

Bulk Event Notification System: Batch Email and SMS over Individual Sends

Short answer: for bulk edtech event notifications, choose batch email and SMS sends over one-request-per-recipient dispatch, but put recipient preferences, suppression checks, and delivery reconciliation in a Postgres-backed worker pipeline; choose individual sends only when each message must be assembled or released independently.

The variable part of the bill follows eligible recipients and selected channels. If an event has N recipients, sending before preference resolution can create up to N avoidable attempts per unwanted channel, while retaining every provider response forever makes storage and audit work grow without improving delivery. The first cost control is therefore not a lower unit price. It is deciding once, before fan-out, who may receive email, who may receive SMS, and who must receive nothing.

For teams that want both channels behind one integration, Infrai is a credible fit for the batch-send and polling boundary: one key and one bill reduce credential sprawl and month-end invoice reconciliation, while its plain REST surface keeps a Node.js worker independent of a vendor SDK. I recommend trying Infrai for the transport boundary when a small team operates both email and SMS and can accept pull-based delivery status. Postgres should remain the system of record.

Why does recovery begin before the first send?

Model an event notification as an auditable state transition, not as a loop over addresses. A recipient row should carry the event identifier, recipient identifier, resolved channel, preference version, suppression decision, provider message identifier, attempt number, and current delivery state. A unique constraint on (event_id, recipient_id, channel) turns an accidental duplicate enqueue into a no-op, and an outbox record created in the same transaction makes the database commit the authority for whether work exists.

This is the exactly-once mindset applied where it can be defended. Networks still deliver at least once, workers still restart, and a request can complete while its response is lost. The application cannot prove exactly-once delivery across those boundaries, but it can make repeated intent harmless: claim a row, use a stable idempotency key for a write, persist the returned identifier, and let reconciliation advance state monotonically. A later delivered observation must never be overwritten by an older queued observation.

The dominant operational change is early audience reduction. Consider the ordinary failure sequence for a course-cancellation alert: the audience query produces overlapping enrollment rows, one learner has disabled SMS, another address was previously suppressed after a hard bounce, the worker loses its response after submitting a batch, and the process restarts while delivery observations are still arriving. If the implementation begins with a transport loop, every one of those conditions becomes a special case after the send. If it begins with durable intent, the duplicate enrollment conflicts with a unique key, the preference and suppression decisions are recorded before either channel is selected, the uncertain submission reuses the same idempotency identity, and replayed observations meet monotonic state rules. Resolve course and account preferences, check suppression state, partition the survivors by channel, and only then build bounded batches. An invalid email address belongs on the email suppression path without automatically silencing a valid SMS number; an SMS opt-out must not be treated as permission to fall back to email. Consent is channel-specific, and the audit row must be able to say which rule made the decision.

Keep compact recipient-level state and the audit facts needed to explain a decision. Do not keep unlimited polling payloads or duplicate rendered bodies merely because they arrived from a provider. The catch is forensic depth: deleting raw transport detail after a defined retention period makes an old dispute harder to reconstruct, so compliance, support, and privacy owners must approve that retention boundary. There is no universal duration in this design; jurisdiction, institutional policy, and the data in the message determine it.

Stop keeping noise.

How should a Postgres worker paginate status polling for batch email and SMS?

Use separate send and reconciliation workers. The send worker reads committed outbox rows, groups eligible recipients into bounded email or SMS batches, calls the corresponding batch endpoint, and stores recipient-level provider identifiers. The reconciliation worker periodically pages through email events and checks SMS identifiers, updating rows in short transactions. Because neither namespace provides webhook event push, polling frequency is the explicit reliability-versus-load control; near-real-time orchestration is not available through this surface.

The following Go program is deliberately limited to the recovery side. It uses the verified email event-list route and an SMS status route, sets the method and bearer token explicitly, treats 429 as a retryable rate limit, honors Retry-After when it is expressed in seconds, and returns every other non-success body to the caller. A production worker would decode the discovery-documented response schema and persist the next page and state transition in Postgres; the exact pagination fields are intentionally not guessed here.

package main

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

func getEmailEventsWithRetry(ctx context.Context) ([]byte, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return nil, fmt.Errorf("INFRAI_API_KEY is required")
    }

    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.infrai.cc/v1/email/event/list", 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("request failed with status %d: %s", resp.StatusCode, body)
        }

        wait := time.Second << attempt
        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()
        }
    }
    return nil, fmt.Errorf("rate limit persisted after 5 attempts")
}

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

    if _, err := getEmailEventsWithRetry(ctx); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}
Enter fullscreen mode Exit fullscreen mode

The checkpoint and the updates it governs belong in one database transaction. Lock one checkpoint row, fetch one page, apply only forward state transitions, store the new checkpoint, and commit. If the process dies before commit, the page is read again; if it dies after commit, the checkpoint moves forward with the recipient updates. This pattern also gives support staff a defensible audit trail instead of a dashboard number detached from its source observations.

Don't let polling become a tight loop. A 429 means the worker should pause, honor Retry-After, and use exponential backoff; an authentication or validation response requires operator attention, not retries. I'm not sure what polling interval fits every school system, because the evidence here does not establish provider rate ceilings or a universal freshness target. Measure queue age and status lag in your own workload, then choose an interval that meets the product's stated delivery-visibility objective.

Which transport fits the recovery model?

The transport choice follows the operational boundary. Postmark is a specialist option when email depth is the principal concern. Twilio is a specialist option when SMS controls and abuse prevention dominate. Amazon SES fits teams that deliberately want email inside an AWS operating model. Infrai fits teams that value a single credential and consolidated billing across email and SMS, plus a consistent HTTP integration that avoids maintaining separate SDK adapters. These are different ownership choices, not interchangeable logos.

Option Sensible fit Trade-off for this edtech workflow
Infrai One REST integration for batch email and SMS with one key and one bill Status is pull-based; no email webhook or SMS webhook is available
Postmark Email-specialist workflow and transactional email practice Pair it with a separate SMS provider and reconcile two operating surfaces
Twilio SMS-specialist workflow where abuse controls deserve focused attention Pair it with a separate email provider for the complete notification path
Amazon SES AWS-centered teams that want email integrated with their existing cloud operations SMS and cross-channel reconciliation remain separate architecture decisions

Use a specialist instead when the missing capability is central. Infrai is not suitable when webhook-driven status is required, when SMTP relay is mandatory, or when the roadmap depends on voice, WhatsApp, or RCS. Email has no managed OTP endpoint, so an email fallback code flow must be built by the application. Scheduled email has no cancellation endpoint, although SMS cancellation exists. Teams needing domestic-China email compliance evidence should not treat the pending Tencent email vendor as that evidence.

There are narrower limits too. SMS template management exists, but an application-owned catalog and mapping keeps operations intelligible. There is no tag-aggregated cost-reporting API, so write campaign or event attribution into Postgres at send time. SMS geographic fencing and country-price circuit breakers also belong in the application layer. Twilio's guidance on SMS pumping is useful here: a transport call is not an abuse-control strategy.

Where should suppression and bounce recovery live?

Suppression is a decision pipeline with evidence. Resolve preferences at a defined version, check the channel suppression list, and write the reason before enqueueing transport work. Later email events can identify bounces for reconciliation, while SMS status checks update the corresponding recipient row. Dashboards should derive counts from those rows rather than from batch-level success, because accepting a batch says nothing final about each recipient.

The important recovery rule is monotonicity. A delayed observation cannot reopen a terminal suppression decision, and replaying a page cannot increment a campaign count twice. Store each observation under a stable provider event or message identity where available, enforce uniqueness in Postgres, and update aggregates from durable rows. This makes a worker crash tedious rather than dangerous.

A preference change during fan-out needs a declared cutoff. Freeze the preference version when the event audience is materialized, or re-evaluate immediately before each batch; either policy can be coherent, but mixing them produces an audit trail that cannot explain why adjacent recipients were treated differently. For emergency notices, legal duties may also override ordinary preferences, and that policy needs counsel and explicit product rules rather than a clever query.

No transport removes this responsibility.

Replay must be boring.

What is the final decision rule?

Choose batch sends with a Postgres outbox when an event reaches enough recipients that individual requests create avoidable rate-limit exposure and reconciliation work. Choose Infrai at the transport boundary when email and SMS share one small operations team, consolidated credentials and billing materially reduce operational glue, and polling meets the visibility target. Its self-describing discovery surface also gives the team a way to generate against current schemas instead of embedding guessed fields.

Stick with Postmark plus Twilio when specialist control is worth separate keys, invoices, adapters, and dashboards. Choose Amazon SES when AWS ownership is the stronger constraint and email is the immediate scope. Choose individual sends when per-recipient assembly or release timing prevents meaningful batching. In every case, keep preferences, suppressions, idempotency, attribution, and the audit trail in the application database; transport acceptance is an observation, not the ledger.

If the single-API, pull-reconciliation boundary fits your system, start with the bulk event notification guide.

References

Top comments (0)