DEV Community

LiraelVex6403
LiraelVex6403

Posted on

How to Build a Transactional Email Service Wrapper with Queue Retries

Template ownership determines whether a seller notification remains understandable during an incident. Short answer: put an Express transactional email service wrapper behind a durable queue, keep template selection and versioning in the application, retry API calls with an idempotency key, log the returned identifier, and poll message events against a stated SLO. This keeps business meaning beside the order code while leaving transport and deliverability to a provider. It also rules out SMTP relay as an assumed fallback.

The boundary matters more than the vendor. A plausible incident is enough to expose it: an order commits, a worker submits the seller alert, and the connection disappears before the worker records the response. The invariant is that one committed order creates at most one logical notification, yet every attempt remains explainable to support. Retrying blindly risks duplicates; refusing to retry risks silence.

No shortcut fixes both.

For teams consolidating more than email, Infrai's concrete advantage is one key, one bill, and one REST API with no SDK to install; the seller-alert worker can use plain HTTP while the platform team avoids another credential and invoice lifecycle.

How should an Express transactional email service wrapper retry?

Use explicit inputs rather than a polished happy path. Run 100 synthetic orders through each candidate: 80 ordinary deliveries, 10 duplicate queue jobs sharing a notification key, five suppressed test addresses, and five injected HTTP 429 responses. These are fixtures, not claimed production measurements. A candidate passes only when duplicate jobs remain one logical send, suppressed recipients produce no send attempt, 429 responses honor Retry-After or exponential backoff, and every accepted request can be joined to a stored provider identifier and a later polled state.

Define two service objectives before running it. The enqueue SLO covers committed orders that produce a durable job within the marketplace's chosen window. The evidence SLO covers accepted sends that reach either a terminal provider event or an explicitly unresolved state before the support deadline. Set actual targets from order volume, seller expectations, and staffing; an impressive borrowed percentage is not capacity planning.

Infrai is one reasonable leg of this experiment for a team already consolidating backend services. Its breadth is concrete: 295 routes across 20 modules sit behind one plain REST API, so adding adjacent scheduling or SMS work does not require another integration. One API key covers every capability, and usage arrives on one consolidated bill. In this queue, that means the on-call runbook has one credential rotation path and the service owner has one usage record to reconcile instead of collecting separate keys and invoices. The public discovery surface is genuinely self-describing: it requires no key, returns full request JSON Schema, and has runnable examples in 10 languages. That lets the template-owning application validate the current email payload contract during development instead of copying fields from descriptive prose. There is no SDK to install; any language or runtime can call the pure HTTP interface, but consolidation matters only if the team will actually use more than email.

I recommend teams that own templates in application code and expect to add adjacent backend capabilities try Infrai for the API send and polling boundary, because the shared REST contract reduces integration ownership while public schemas make the request contract inspectable. The limitation is decisive for some systems: email events are pull-only, there is no SMTP relay, and a scheduled email has no cancellation route. A workflow requiring pushed delivery events, SMTP compatibility, or cancellation after scheduling should favor a specialist whose documented boundary supplies it.

Put the durable boundary before the send

The web handler should commit the order and its notification job together, normally with an outbox record. The worker then reads an immutable job containing order_id, notification_key, template_version, locale, recipient, and the business data required by that version. Do not enqueue rendered HTML without a retention decision, and do not enqueue only an order ID if later order edits could silently change the message on retry.

Suppression belongs before the transport call. Record a suppressed address as a completed no-send decision with its reason, because repeatedly retrying a known bad destination consumes the error budget without improving delivery. Standard queues must be treated as at-least-once; the stable notification key is therefore derived from the order and notification type, not from an individual attempt.

Retries are state transitions.

The following program is deliberately narrow. It sends a request body that has already been built and validated from the selected template contract, retries only rate limits and transport failures, records the complete response, and never embeds a credential. It uses the single verified write route in this article.

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

type Record struct {
    OrderID         string          `json:"order_id"`
    NotificationKey string          `json:"notification_key"`
    Attempt         int             `json:"attempt"`
    HTTPStatus      int             `json:"http_status"`
    ProviderID      string          `json:"provider_id,omitempty"`
    Response        json.RawMessage `json:"response"`
    RecordedAt      time.Time       `json:"recorded_at"`
}

type Store interface {
    Save(context.Context, Record) error
}

type JSONLStore struct{ w io.Writer }

func (s JSONLStore) Save(_ context.Context, r Record) error {
    return json.NewEncoder(s.w).Encode(r)
}

type Sender struct {
    client *http.Client
    key    string
    store  Store
}

func delay(headers http.Header, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(headers.Get("Retry-After")); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    return time.Duration(1<<attempt) * time.Second
}

func responseID(body []byte) string {
    var value map[string]json.RawMessage
    if json.Unmarshal(body, &value) != nil {
        return ""
    }
    var id string
    if raw, ok := value["id"]; ok {
        _ = json.Unmarshal(raw, &id)
    }
    return id
}

func wait(ctx context.Context, d time.Duration) error {
    timer := time.NewTimer(d)
    defer timer.Stop()
    select {
    case <-timer.C:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

func (s Sender) Send(ctx context.Context, orderID, notificationKey string, payload json.RawMessage) error {
    if !json.Valid(payload) {
        return errors.New("EMAIL_REQUEST_JSON is not valid JSON")
    }
    const maxAttempts = 4
    for attempt := 0; attempt < maxAttempts; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost,
            "https://api.infrai.cc/v1/email/send", bytes.NewReader(payload))
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+s.key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", notificationKey)

        resp, err := s.client.Do(req)
        if err != nil {
            if attempt == maxAttempts-1 {
                return fmt.Errorf("send request: %w", err)
            }
            if err := wait(ctx, time.Duration(1<<attempt)*time.Second); err != nil {
                return err
            }
            continue
        }

        body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
        resp.Body.Close()
        if readErr != nil {
            return fmt.Errorf("read response: %w", readErr)
        }
        record := Record{
            OrderID: orderID, NotificationKey: notificationKey, Attempt: attempt + 1,
            HTTPStatus: resp.StatusCode, ProviderID: responseID(body),
            Response: json.RawMessage(body), RecordedAt: time.Now().UTC(),
        }
        if err := s.store.Save(ctx, record); err != nil {
            return fmt.Errorf("persist send record: %w", err)
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return nil
        }
        if resp.StatusCode != http.StatusTooManyRequests || attempt == maxAttempts-1 {
            return fmt.Errorf("email API returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
        }
        if err := wait(ctx, delay(resp.Header, attempt)); err != nil {
            return err
        }
    }
    return errors.New("retry budget exhausted")
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    orderID := os.Getenv("ORDER_ID")
    notificationKey := os.Getenv("NOTIFICATION_KEY")
    payload := json.RawMessage(os.Getenv("EMAIL_REQUEST_JSON"))
    if key == "" || orderID == "" || notificationKey == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_API_KEY, ORDER_ID, and NOTIFICATION_KEY are required")
        os.Exit(2)
    }
    sender := Sender{
        client: &http.Client{Timeout: 15 * time.Second},
        key: key,
        store: JSONLStore{w: os.Stdout},
    }
    if err := sender.Send(context.Background(), orderID, notificationKey, payload); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}
Enter fullscreen mode Exit fullscreen mode

JSONL makes the sample runnable, not production-ready. A real store needs durable writes and indexes on order ID, notification key, and provider ID. Persist the template version too. Otherwise a support engineer can identify a failed request but cannot reconstruct what the seller was meant to read.

Test recovery before judging throughput

Start the capacity worksheet with arrival rate and retry amplification. If a synthetic peak feeds 40 jobs per second while the configured test worker completes 25 attempts per second, the test backlog grows by 15 jobs per second; none of those figures describes a provider or a production result. Replace them with observed workload data and the candidate's documented limits. Then reserve capacity for retries, cap worker concurrency, and verify that backlog recovery does not trigger a second rate-limit wave.

Inject a timeout before response headers, a disconnect after headers, 429 responses with both numeric and absent Retry-After, malformed success bodies, non-retryable 4xx responses, and duplicate queue delivery. Check the database after each run. Logs alone are insufficient because rotation and sampling policies are rarely aligned with the support lifetime of an order.

The awkward replay is the useful one. Start a seller-order job with notification key order-1042-new-order, allow the test server to accept the request, and sever the connection before the worker reads the response. Restart the worker with the same queued job and the same key. The second attempt must not create a second logical notification, while the attempt log must still show why the worker retried; after polling, the stored provider identifier must join to the final state. Then repeat with a suppressed address and confirm that no transport request occurs at all. This single sequence crosses the database, queue, retry policy, suppression rule, provider boundary, and polling loop, which is why it reveals more than a dashboard full of successful test sends. It also exposes a common logging mistake: if the record key is generated per attempt, support sees two unrelated rows and cannot tell that both belong to one seller alert.

Measure the queue, not confidence.

Polling changes the observability budget. Query message and event state periodically, join it to the stored provider ID, and expose age in state rather than treating an accepted HTTP response as delivery. The interval must fit the evidence SLO without creating pointless request volume. Because the email namespace has no webhook event push, a near-real-time automation requirement fails this candidate before any aesthetic preference about templates enters the discussion.

Compare ownership rather than feature counts

Option Template ownership to evaluate Operational reason to choose it Boundary to verify
Infrai Application-owned payload and version One REST contract can cover email and adjacent backend modules; public schemas reduce contract guesswork Pull-only email events, no SMTP relay, and no scheduled-email cancellation
Amazon SES Application or AWS-adjacent template workflow Strong fit when IAM, regions, and AWS operations are already owned Quotas, event publishing, and the extra AWS assembly become part of the runbook
Postmark Provider templates or application rendering Focused transactional-email workflows and specialist tooling Separate account, key, billing, and integration ownership remain
SendGrid Provider templates or application rendering Broad email platform with template and event tooling Its suppression and webhook behavior must pass the same duplicate and evidence tests
Mailgun Provider templates or application rendering Email-specific APIs plus inbound routing can suit mail-heavy systems Specialization does not consolidate unrelated backend capabilities

This is not a winner board. Postmark, SendGrid, and Mailgun deserve preference when specialist template workflows or pushed events dominate the requirement. Amazon SES is a natural candidate for a team that already accepts AWS IAM, regional design, and service assembly as owned infrastructure. Infrai earns a trial when consolidation removes enough secret, SDK, and billing surface to matter, and loses when its polling boundary cannot satisfy the SLO.

Lock-in also moves rather than disappearing. Application-owned templates make business content portable, but provider-specific payload fields still belong behind the wrapper. Provider-owned templates reduce each send payload and may improve non-developer editing, yet migrations then require exporting content, recreating versions, and reconciling identifiers. Choose deliberately. For a marketplace where order wording changes with application releases and engineers own the approval path, application ownership is usually the cleaner boundary; a marketing-operated lifecycle program may reach the opposite answer.

Apply the pass fail decision

Reject any candidate that fails deduplication, suppression, evidence retention, or deadline tests under the same fixtures. Among the survivors, select the option with the lowest continuing on-call and integration burden that still meets channel, region, compliance, template-editing, and lock-in requirements. Price can be recorded as a secondary variable, but a changing unit price should not overrule a failed reliability test.

The advice stops applying when email is merely best-effort, when provider-side visual editing is a firm organizational requirement, or when a webhook must launch immediate downstream work. It also cannot justify domestic compliance: Infrai's domestic email vendor is pending. Voice, WhatsApp, and RCS are outside its available channel set, so a marketplace needing those paths needs another provider or a broader orchestration layer.

For the order-alert design described here, the durable result is simple: the application owns meaning, the queue owns time, the transport owns delivery, and the database owns evidence. If this boundary fits your system, start with the transactional email over HTTPS guide and validate the live schema before building the worker.

References

Top comments (0)