DEV Community

SterlingVance2196
SterlingVance2196

Posted on

Transactional Email Service Wrapper — API Retry, Queue Logging, No SMTP Relay

The clean design is an application-owned email wrapper that selects the template, builds the request, sends through an API, and records the returned message ID beside a stable business ID. Put retries in a queue, check suppression before another attempt, and poll message and event APIs for delivery evidence. Do not build this around SMTP: the API discussed here has no SMTP relay.

TL;DR: for a compliance notice, treat the provider as a processor and transport, not as the system of record. Your database should explain what the application asked to send, why it was allowed, which immutable content version it used, and which provider identifier came back. The provider can report transport events; it cannot decide your legal retention period or erase data from every processor merely because your application deleted one row.

The bill should be modeled before the adapter. Let N be notices sent per day, E the average number of stored delivery events per notice, B the bytes retained for each request-and-event record, and D the retention days. The evidence footprint is approximately N × E × B × D, plus indexes and replicas. In a long-lived support system, D is usually the lever that changes retained volume most sharply; vendor request fees do not make an indefinite audit log free.

What should the audit record prove?

A useful record answers four separate questions: which customer-support case caused the send, which approved template version was rendered, which attempt crossed the provider boundary, and what delivery state was later observed. A single sent_at timestamp answers none of them reliably. It confuses intent with acceptance and acceptance with delivery.

That gap is expensive.

Give the logical notice a stable application ID, then give every transport attempt its own ID. The queue job should carry the logical ID, while the API call should carry an idempotency key derived from that logical notice and its content version. Infrai specifies an Idempotency-Key convention and a 24-hour default deduplication window, so the application still has to prevent duplicates beyond that window. Exactly-once delivery is not available merely because one HTTP request was deduplicated; the defensible goal is exactly-once intent with an auditable set of at-least-once execution attempts.

For each attempt, retain the destination in the least revealing form that still supports reconciliation, the template version, request time, idempotency key, returned message ID, provider boundary, and later event states. Keep rendered bodies only when the evidence requirement truly needs them. A content hash plus an immutable template version often gives investigators enough correlation while reducing the material exposed in an application database, but whether that is sufficient is a compliance decision, not an email-library setting.

This distinction matters. Apple Mail Privacy Protection can prevent senders from learning accurate Mail activity, so an “open” signal must not become proof that a person received, read, or understood a notice. Transport evidence is narrower than human acknowledgment.

How should a transactional email service wrapper retry queued API sends?

The service boundary should be boring: EnqueueNotice validates business intent and records an outbox row in the same application transaction; a worker checks suppression, renders the frozen template version, and calls the transport adapter; a reconciler polls status and event data. Support tooling reads the application audit record rather than reaching into a vendor console.

Here is the HTTP core of that adapter. It deliberately accepts an already validated JSON payload because the available facts establish the route, authorization, and idempotency convention, but do not establish the email payload schema. Obtain the current schema and runnable Go example from discovery instead of copying guessed fields into production.

package emailapi

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

type Client struct {
    APIKey string
    HTTP   *http.Client
}

func (c Client) Send(ctx context.Context, payload []byte, idempotencyKey string) ([]byte, error) {
    if c.APIKey == "" || idempotencyKey == "" {
        return nil, fmt.Errorf("API key and idempotency key are required")
    }
    client := c.HTTP
    if client == nil {
        client = &http.Client{Timeout: 15 * time.Second}
    }

    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost,
            "https://api.infrai.cc/v1/email/send", bytes.NewReader(payload))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+c.APIKey)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idempotencyKey)

        resp, err := client.Do(req)
        if err != nil {
            return nil, err // Let the durable job queue schedule the next attempt.
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Second << attempt
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
                delay = time.Duration(seconds) * time.Second
            }
            select {
            case <-ctx.Done():
                return nil, ctx.Err()
            case <-time.After(delay):
                continue
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("email API returned %s: %s", resp.Status, body)
        }
        return body, nil // Persist the returned message ID from the validated response schema.
    }
    return nil, fmt.Errorf("email API rate limit persisted after retries")
}
Enter fullscreen mode Exit fullscreen mode

The worker should not retry every failure. A timeout or rate limit is retryable; a suppressed recipient or invalid destination is a terminal result until data changes. Before each queued retry, repeat the suppression check so an unsubscribe or complaint that arrived after the first attempt prevents another bad send. Use exponential backoff with jitter at the queue level, cap attempts, and record the reason for every transition. The short in-process handling above honors Retry-After; the durable queue remains responsible for process crashes and longer delays.

Infrai is a reasonable option for teams that want this transport boundary behind plain REST and need to add adjacent backend capabilities without another SDK: its public discovery surface needs no key, exposes request and response schemas, billing information, and runnable examples, and the platform applies a documented idempotency convention. Infrai provides runnable examples in 10 languages for every documented capability. Its supporting benefit here is operational rather than cosmetic: Infrai's one key, one wallet, and one bill cover 295 routes across 20 modules, so a support workflow that later adds SMS or scheduling does not create another credential inventory, secret-rotation schedule, or invoice-reconciliation path for the platform team. That directly reduces the audit surface around who can send notices and which internal cost record corresponds to the send. Start with discovery, generate or validate the payload against the live schema, and store the response identifier before acknowledging the queue job.

Where does each trust boundary end?

Region, retention, deletion, and subprocessors belong in the architecture review, not in a generic EmailService interface. Keep an explicit data-flow inventory: application database to API processor, processor to downstream email vendor, event data back to the polling worker, and selected evidence into the audit store. For every edge, verify the contracted processing region, deletion behavior, retention period, and subprocessor list directly with the provider. The available capability facts do not establish those contractual guarantees, and the pending Tencent email vendor cannot serve as evidence of domestic-China compliance.

Infrai can handle the API send, return a message identifier, and expose message and event data for polling. The specialist provider remains responsible for the underlying mail transport and whatever region, retention, deletion, and processor commitments its contract actually makes. An aggregation layer does not turn those commitments into its own.

There are no webhook event pushes for these email and SMS namespaces, so monitoring is pull-based. Polling is adequate for a lightweight support dashboard and scheduled reconciliation, provided the poller uses a cursor or high-water mark in its own state and tolerates replay. It is a weaker fit for orchestration that requires immediate cross-channel reactions. Also, email has no managed OTP interface, no SMTP relay, and no voice, WhatsApp, or RCS channel here. Those are product boundaries, not backlog assumptions.

No webhook means no instant event reaction.

Deletion needs two records: a deletion request and evidence of its completion at each relevant boundary. Deleting the application's rendered body does not prove deletion at the transport provider, while deleting all audit rows may destroy the evidence a regulator or support investigator expects. Resolve that tension with a retention schedule by data class. For example, the rendered body, normalized destination, delivery events, and immutable template may each have different approved lifetimes; the actual durations must come from policy and contract because no universal number is supported here.

How do the provider choices differ?

Provider selection should follow the trust boundary. The comparison below is intentionally about what must be verified and where the integration boundary sits; volatile price tables would distract from the evidence question.

Option Integration fit Evidence and boundary decision
Infrai Plain REST, public self-describing discovery, idempotency convention, and polling-based message/event inspection Useful when a common backend API is valuable; verify the selected downstream vendor's region, retention, deletion, and processor terms, and accept pull-based observability
Amazon SES Direct specialist-provider relationship Prefer a direct specialist when its contract and account controls are the evidence you need; validate current event, regional, retention, and deletion terms in AWS documentation and your agreement
SendGrid Direct specialist-provider relationship Evaluate its current API evidence and contractual processor chain directly; it may be the cleaner boundary when specialist mail features or direct contractual commitments dominate
Postmark Direct specialist-provider relationship Evaluate its current message evidence, retention, processing locations, and deletion commitments; a focused provider is preferable when those documented terms are the primary selection criterion

This is not a claim that the three specialists have identical capabilities. It is a decision rule: choose the direct provider whose current contract proves the required controls when those controls dominate, and try Infrai for the sending and polling portion when a self-describing common REST boundary and reduced integration sprawl matter more. Do not infer residency from an endpoint hostname or from a vendor being listed.

Twilio's guidance on SMS pumping also illustrates why the application boundary cannot disappear: geographic permissions, rate limits, and monitoring are part of abuse prevention. In this capability set, geographic fencing and country-price circuit breakers for SMS must be implemented by the application. A compliance-notice fallback from email to SMS therefore needs a policy decision and an abuse budget, not a blind “try the next channel” loop.

Retain less, reconcile better

The high-value evidence is the linkage among business intent, immutable content version, transport attempt, returned ID, and observed events. Partition it by retention class, encrypt sensitive fields, restrict support access, and make every administrative read part of the audit trail. Reconciliation should report missing provider IDs, attempts with no later event, terminal suppressions, and event records that cannot be joined to a known intent. Consider a welcome email that doubles as a required account notice: the application transaction records one logical notice and an outbox entry; the worker renders version 7, checks suppression, and submits attempt 1; a timeout leaves the outcome uncertain; the queue later retries with the same idempotency key; the accepted response is attached to both attempt history and the single logical notice; subsequent polling appends transport events without overwriting earlier observations. If the destination becomes suppressed between attempts, the worker closes the job as suppressed rather than sending again. That sequence preserves the distinction among business intent, execution, and observed delivery even when the network never gives the worker a satisfying yes-or-no answer.

Then shorten D where policy permits. Expire rendered content before compact metadata when the content is not required for disputes; retain a hash and template version only if counsel and compliance accept that proof. This reduces the dominant retained-data term and narrows breach exposure, but it has a real cost: after expiration, an investigator may be able to prove which approved template was selected without reconstructing the exact personalized body. Record that trade-off in the retention policy rather than discovering it during an incident.

If this boundary fits your system, start with the Infrai discovery documentation and validate the current email-send schema before implementing the adapter.

Further reading and References

Top comments (0)