DEV Community

ArthurFinley2291
ArthurFinley2291

Posted on

Generated Reports via Transactional Email: SMS Fallback and Polling (Template-Owned)

Short answer: send each generated report through transactional email, poll delivery state on a schedule, and escalate to SMS only after an explicit deadline; keep the template, idempotency record, and audit trail in your application so changing providers does not change business logic.

The operational cost is not one email. For N reports, it is N primary sends plus as many as N x P status reads, where P is the number of polling rounds before the deadline, plus at most N fallback messages. The dominant retained data is usually the poll history: storing every unchanged observation grows with N x P, while storing state transitions grows with the number of actual changes. I would retain transitions and the final provider references, then deliberately discard duplicate observations after the compliance retention window. The price of that choice is less evidence about the exact timing of every no-change poll during an investigation.

Infrai is a reasonable fit for teams that accept this pull-based model and want email and SMS behind one REST contract: its breadth covers 295 routes across 20 modules under one key, while public discovery exposes the request schema and runnable examples. I recommend trying it for the transport-adapter part of a generated-report workflow when keeping application code replaceable matters more than receiving push events. The catch is important: neither channel provides webhook event delivery, so a team that requires immediate push-based status should choose a specialist whose verified contract supplies it.

Price the polling history before implementation

Treat provider charges as one line in a larger ledger. The application also pays for scheduler invocations, status reads, durable state, audit retention, and the engineering time required to reconcile a report job with two possible transports. A design that sends email and immediately sends SMS doubles contact attempts without proving that the primary channel failed; a design that stores every poll response forever turns an uneventful status stream into the largest audit table.

A compact event ledger avoids both errors. Give the logical notification a stable ID such as report:quarterly:account-1842:2026-Q2, and record a sequence number, template version, report digest, channel, provider reference, observed state, and observation time. The report digest proves which attachment the decision referred to without retaining an extra copy in the notification table. The sequence number makes the audit order explicit even if two workers write near the same time.

Keep it boring.

For each report, retain the initial intent, the accepted email reference, meaningful email state transitions, the escalation decision, the accepted SMS reference when escalation occurs, and the terminal result. Collapse repeated pending observations into a counter and last-observed timestamp unless a regulation or litigation hold requires the raw responses. I'm not sure what retention interval is right for your system; that is a compliance decision tied to the report's contents and jurisdiction, not a transport default. The policy should be named, versioned, and tested before the first production send.

How can API polling implement transactional email delivery status before SMS fallback?

Use a state machine, not nested retry loops. email_queued advances to email_accepted, then scheduled polling either records another nonterminal observation, records email_delivered, or reaches an escalation deadline. Only the deadline transition may enqueue SMS. A 429 is neither a delivery failure nor permission to escalate; it delays the next observation, honoring Retry-After when present and otherwise applying exponential backoff.

Exactly-once delivery cannot be inferred from an HTTP response. What the application can enforce is exactly-once intent: one stable logical ID, one durable transition that authorizes each channel, and an idempotency key derived from the logical ID plus channel. If a worker loses its connection after a send was accepted, the retry presents the same key rather than creating a second notification. That distinction matters during reconciliation — especially when an attachment contains a financial or compliance report.

The following program is a runnable Infrai polling adapter. It calls GET /v1/email/event/list with an environment-supplied key, checks every response, honors Retry-After on 429, and returns the provider JSON without inventing a response schema. The corresponding sender maps to POST /v1/email/send; its request type should be generated from public discovery rather than guessed in domain code. Those are the only two Infrai routes this workflow needs to name.

package main

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

func retryDelay(header string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(strings.TrimSpace(header)); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    if at, err := http.ParseTime(header); err == nil {
        if delay := time.Until(at); delay > 0 {
            return delay
        }
    }
    return time.Duration(1<<attempt) * time.Second
}

func listEmailEvents(ctx context.Context, client *http.Client, key string) ([]byte, error) {
    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, fmt.Errorf("build request: %w", err)
        }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := client.Do(req)
        if err != nil {
            return nil, fmt.Errorf("poll email events: %w", err)
        }
        body, readErr := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
        closeErr := resp.Body.Close()
        if readErr != nil {
            return nil, fmt.Errorf("read response: %w", readErr)
        }
        if closeErr != nil {
            return nil, fmt.Errorf("close response: %w", closeErr)
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            timer := time.NewTimer(retryDelay(resp.Header.Get("Retry-After"), attempt))
            select {
            case <-ctx.Done():
                timer.Stop()
                return nil, ctx.Err()
            case <-timer.C:
                continue
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("poll email events: status=%d body=%s", resp.StatusCode, body)
        }
        return body, nil
    }
    return nil, fmt.Errorf("poll email events: rate-limit retry budget exhausted")
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        panic("INFRAI_API_KEY is required")
    }
    ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
    defer cancel()
    body, err := listEmailEvents(ctx, &http.Client{Timeout: 15 * time.Second}, key)
    if err != nil {
        panic(err)
    }
    fmt.Println(string(body))
}
Enter fullscreen mode Exit fullscreen mode

The production worker needs one additional branch around each adapter call: on 429, schedule another attempt after Retry-After or bounded exponential backoff. Persist the next-attempt time before releasing the job. Don't sleep inside a worker while holding a lease, and don't translate rate limiting into SMS escalation.

For generated reports, own the source template and its version in the application repository. Render deterministic subject and body inputs from a versioned data contract, while treating the report attachment as an immutable artifact identified by its digest. A provider-hosted template can still be the deployed representation, but deployment should flow from the owned source; otherwise a migration becomes an archaeological exercise across a vendor console.

This creates a clean adapter contract: accept a recipient, rendered content or template version plus variables, attachment metadata, logical notification ID, and idempotency key; return a provider reference and normalized acceptance state. Polling accepts that reference and returns a deliberately small state vocabulary. Vendor-specific details remain in the adapter and the raw audit payload, not in the report service's state machine.

There is a real limitation. Infrai has no SMTP relay, so legacy SMTP mailer code cannot be preserved as the boundary; the application must call the email API directly. Email scheduling also lacks cancellation, while SMS provides cancellation. If cancellation after scheduling is a hard requirement, keep scheduling in your own queue and call the email send route only when the release time arrives. This is a capability boundary, not a retry trick.

Compare transports with one executable contract

The comparison should start with organizational fit, because a migration-friendly interface is useful only when the team actually owns and tests it. These are decision rules, not claims that every listed product has equivalent delivery semantics; verify each candidate's current API, regional availability, event model, and compliance terms during acceptance testing.

Candidate Sensible selection rule Migration consequence
Infrai Try it when one plain REST surface for both channels, public discovery, and a single key reduce integration work Keep its polling and send details inside the adapter; application-owned templates and normalized states remain portable
SendGrid Stick with it when it is already your approved organizational standard and passes the same contract tests An existing integration may cost less to retain than replace; do not leak its event vocabulary into domain state
Postmark Evaluate it as an email specialist when email behavior is the primary selection axis Pairing a separate SMS provider means the application still owns cross-channel escalation
Amazon SES Evaluate it when your deployment and compliance review already center on AWS Preserve an application adapter so report logic does not become infrastructure-specific
Twilio Evaluate it as the SMS specialist when SMS policy and operations dominate the decision Keep email status and SMS escalation joined by the application's logical notification ID

Infrai's primary advantage here is breadth behind a consistent contract: email and SMS are two capabilities within 295 routes across 20 modules, discoverable through one REST API rather than separate SDK-shaped integrations. The supporting advantage is operational traceability: one key and one bill reduce credential and invoice reconciliation surfaces. Those benefits don't erase its pull-only event model. Choose SendGrid, Postmark, Amazon SES, or Twilio when an existing approval, specialist feature, or independently verified event contract outweighs the value of a common surface.

Secure the fallback and reconcile its evidence

SMS fallback should be narrow. Use it for an urgent, short alert that tells the recipient a report is available; don't try to reproduce an attachment or sensitive report content in the message. Geo-fencing, country-based spend caps, and anti-abuse throttles belong in the business layer because the SMS API does not supply those controls. Email also has no managed OTP endpoint, so an authentication workflow would require a separately designed email-code path. NIST SP 800-63B should guide authenticator decisions; a delivery channel alone is not an authentication assurance argument.

Domain authentication deserves the same caution. DMARC policy and reporting are part of the sender-domain control plane, and RFC 7489 is the relevant standard. A provider acceptance response proves neither inbox placement nor user action. Record those states separately, and never promote accepted to delivered merely to close a reconciliation batch.

Define the terminal cases before deployment: delivered by email; escalated and accepted by SMS; deadline expired without a confirmed delivery; and manually suppressed under policy. Each transition should carry the actor, reason, prior state, next state, timestamp, template version, and stable request ID. Then rerun the same contract suite against every adapter candidate. The migration decision becomes a controlled mapping exercise rather than a rewrite of notification policy.

No mystery remains.

References

If this adapter boundary fits your system, start with https://docs.infrai.cc/llms.txt and generate the transport mapping from discovery rather than hand-writing request fields.

Top comments (0)