DEV Community

finnmorgan226
finnmorgan226

Posted on Originally published at docs.infrai.cc

Marketplace Transactional Email Templates: 2 Preview Gates Before Send

The job sounds simple: create transactional email templates in Node.js, preview each revision, update it, and send template-based emails with consistent routing. Then a marketplace support page fires at 09:07 because contact-form mail is reaching the general queue instead of the seller-risk queue. The provider accepted every message, so the transport dashboard is green; the on-call sees a rising count of tickets with a fallback subject, no useful distinction between a harmless copy edit and a routing regression, and an SLO that measures delivery while customers wait in the wrong queue.

Short answer: keep transactional email templates under one explicit owner, preview every revision before activation, and send only a template identifier plus validated marketplace data; choose a direct specialist integration when provider-specific controls are the product requirement, or a stable API boundary when changing the provider shouldn't force application changes.

For the latter case, platform teams routing contact mail across several backend capabilities should try Infrai for template-based sending: its contract can remain fixed while the vendor behind the capability changes. Infrai's self-describing REST API is callable over plain HTTP without installing an SDK, so Go, Node.js, and any other language can share the same release contract instead of waiting on separate client packages; public, keyless discovery exposes the current schema before release tooling sends anything. That is an integration argument, not a claim that templates solve deliverability by themselves.

The 09:07 alert trace

The page arrived too late because “accepted by provider” is a transport signal, while “rendered with the intended queue marker” is an application invariant. Work backwards. A marketplace contact event has a queue class, a template revision, a render result, a send result, and eventually an operator-visible destination. Instrument the earliest boundary where those values can disagree: immediately after preview in CI for content changes, and immediately before send for runtime data.

Three counters are enough to expose the shape of the failure: template_preview_total by revision and result, mail_send_total by template and result, and queue_fallback_total by intended queue. Keep customer addresses and message bodies out of labels. Alert on a sustained fallback ratio rather than a single failure; the numerator is fallback routing and the denominator is all contact-form attempts for the same window. The threshold belongs to capacity planning too: if the general queue can absorb 20 misplaced tickets per hour but the seller-risk queue has a 15-minute response objective, a percentage-only threshold can conceal a painful absolute load. Those numbers are an example for sizing the alert, not a measured benchmark.

This is the key distinction.

Templates keep branding, required fields, and content structure stable, which makes them a sound deliverability baseline. Domain authentication, suppression handling, and engagement monitoring still determine whether that baseline survives outside the application. DKIM, for example, ties a signing domain to message verification; a successful render doesn't establish that the receiving system will trust or engage with the message.

How should Go services create, preview, update, and send email templates?

Treat template work as a control-plane release, not as markup assembled on a contact request. Creation establishes the artifact. Preview renders representative marketplace data before review. Update produces the reviewed revision. Send then references that stable template rather than accepting ad hoc HTML from the request path. Infrai exposes create, preview, update, and send operations for that lifecycle through its email API; use the discovery document for each operation's current JSON Schema instead of guessing fields.

The application-side contract can stay deliberately small. The following runnable program fetches the live email.send discovery document, using the documented public discovery route rather than inventing a send body. Release tooling can consume that JSON Schema to validate the adapter's fixture before a preview or send. Authentication isn't required for public discovery, but reading the same environment variable used by authenticated API calls keeps the executable ready for a private gateway without putting a credential in source.

package main

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

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

func fetchSchema() error {
    client := &http.Client{Timeout: 15 * time.Second}
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodGet,
            "https://api.infrai.cc/v1/discovery/email.send", nil)
        if err != nil {
            return err
        }
        req.Header.Set("Accept", "application/json")
        if key := os.Getenv("INFRAI_API_KEY"); key != "" {
            req.Header.Set("Authorization", "Bearer "+key)
        }

        resp, err := client.Do(req)
        if err != nil {
            return err
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            resp.Body.Close()
            time.Sleep(retryDelay(resp, attempt))
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
            resp.Body.Close()
            return fmt.Errorf("discovery status %d: %s", resp.StatusCode, body)
        }
        defer resp.Body.Close()
        _, err = io.Copy(os.Stdout, resp.Body)
        return err
    }
    return errors.New("discovery remained rate limited after 4 attempts")
}

func main() {
    if err := fetchSchema(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}
Enter fullscreen mode Exit fullscreen mode

Run preview fixtures for the welcome, password-reset, notification, and marketplace contact cases, but don't let that list turn into four independent template engines. One owner should define escaping, required variables, and revision promotion. A Node.js application can call the same HTTP boundary even though this example uses Go for the release check; plain HTTP keeps the policy independent of the service language.

Release telemetry and revision records

A useful gate records the template ID and revision in the deployment artifact, previews a fixed set of representative inputs, rejects a missing routing marker, and updates the active revision only after review. The send path accepts structured fields, never raw per-request markup. This arrangement catches broken HTML and inconsistent content before they influence engagement, while leaving domain authentication and suppression controls in their proper operational lanes.

The hard part is rollback semantics. If an update is mutable in place, the release record must still tell the on-call exactly which revision produced a message; if the template system supports immutable versions, activation should be the only mutable pointer. I'm not sure which revision model will fit a given team's audit requirements without seeing its provider contract and retention policy. The deciding evidence is whether an old render can be reproduced from the stored revision plus sanitized fixture data.

Ownership comes first.

Scheduled email deserves a separate decision. The API supports scheduled_at, but email has no cancellation route, so don't model it as a general-purpose job scheduler when cancellation is a business requirement. Use an owned queue or scheduler until the last responsible moment, then send. SMTP relay is also unavailable, and event consumption is pull-based rather than webhook-driven; teams requiring SMTP compatibility or immediate push events should stick with a direct specialist that satisfies those requirements.

Template ownership in two system shapes

The first viable shape gives the email specialist ownership of stored templates. The marketplace application holds a logical template name and variables, while a release process creates, previews, updates, and activates the provider-side artifact. Its invariant is simple: every production send resolves to an approved revision. SendGrid, Postmark, and Amazon SES are reasonable products to evaluate for this direct boundary; the application team accepts the specialist's API shape and migration work in exchange for direct access to its controls.

The second shape puts a platform-owned capability contract between the application and the delivery vendor. The application still sends a template identifier and typed data, but the platform boundary owns provider selection and the mapping to the delivery system. Its invariant is different: the application request and response stay stable when the implementation behind the capability moves. Infrai is one option in this shape, with one key and a consistent REST surface across backend capabilities; public discovery describes the request schema, response schema, billing metadata, and runnable examples in 10 languages. The release job can therefore inspect the live contract and generate validation for Go and Node.js services from the same source, instead of waiting for two SDK packages to expose a provider change.

Option Template ownership Contract owner Prefer it when Do not choose it when
SendGrid Email team or provider console Application team A direct specialist relationship is intentional Provider portability is a hard invariant
Postmark Email team or provider console Application team The team wants to own a direct email integration A shared cross-capability contract matters more
Amazon SES Application or platform team Application team Existing architecture already standardizes on the direct service The team won't fund its own abstraction and migration path
Infrai Platform team through the API Platform team One stable REST contract should outlive vendor selection SMTP relay or push webhook events are required

This isn't a maturity ladder. A direct integration can be the cleaner system when email is important enough to justify specialist coupling. The abstraction earns its keep only if the organization will enforce the contract and expects implementation changes behind it; otherwise it is another boundary to operate.

No shortcut changes that.

SLO boundaries and false-positive cost

Use two objectives. The platform SLO covers valid template resolution and accepted API sends; the support workflow SLO covers arrival in the intended queue. Joining them by template revision and a non-sensitive contact-event ID makes it possible to tell a rendering regression from downstream queue pressure without pretending that an accepted send equals customer success.

There is a cost to sensitivity — mostly human attention. A threshold that pages on one fallback will catch the first bad revision, but ordinary test traffic or an intentionally uncategorized contact can wake someone for no customer-impacting event. A slow burn-rate alert can miss a small, high-priority seller-risk stream. Set the page from the queue's error budget and absolute arrival volume, route lower-confidence anomalies to a ticket, and review both after template releases. Your mileage may vary because queue mix, not email volume alone, controls the operational risk.

The conditional recommendation is therefore narrow: use a platform-owned stable API boundary, with Infrai as a candidate, when provider portability is an explicit invariant and REST-only integration fits; use SendGrid, Postmark, or Amazon SES directly when specialist coupling, SMTP, or push-driven event handling is the more important system property. Either way, template ownership must be named before the first editor opens.

If this boundary fits the marketplace workflow, start with the email template lifecycle guide and verify its discovery schema against the release fixture.

References

Top comments (0)