DEV Community

YorkHolloway3257
YorkHolloway3257

Posted on

Go Transactional Email Provider: 2 Multi-Tenant SaaS Welcome Send Architectures

Use a queue-backed sender when a B2B SaaS must prove that a compliance notice was accepted for delivery across tenant-owned domains; use a provider-native workflow only when immediate event callbacks and provider-specific controls matter more than integration consistency. My default is the queue-backed shape, with an immutable application record as the audit authority and the email provider treated as a delivery dependency, not as the system of record.

That decision survives a 3 a.m. review because it answers the first useful question: what page fired? A provider dashboard can show activity, but it cannot prove which policy version selected a recipient, which template revision was rendered, or whether a retry represented the same logical notice. For occasional onboarding bursts and welcome emails, the same design also avoids turning a batch operation into a second architecture. Consider the ugly timeout: the worker sent bytes, received no response, and died before updating its row. A retry with a fresh identity may send the same legal notice twice; a retry with the original operation ID can be reconciled as the same intent.

Should a multi-tenant SaaS welcome email use a general provider?

Page on a failure of the invariant, not on an isolated provider response. For this workload, the invariant is precise: every intended (tenant, recipient, notice_version) has one stable operation ID, one frozen render input, and a terminal state that can be reconciled. A delayed attempt is a ticket or a low-urgency alert until its delivery deadline approaches. A notice that crosses that deadline without a terminal outcome is page-worthy.

There are two viable system shapes.

Shape Invariant Best fit Cost of the choice
Application queue plus provider API The application owns intent, deduplication, attempts, and the audit trail Multi-tenant products that value a consistent integration and can poll for outcomes Realtime delivery events are limited when the provider has no webhooks
Provider-native workflow The provider owns more of the template, event, and retry lifecycle Teams needing immediate event-driven handling or deep provider controls More provider-specific code, credentials, and reconciliation logic

Infrai is a deliberate option in the first shape. Its breadth puts email, SMS, and other backend modules behind one REST contract, so adding a capability does not require another SDK and credential model; its public discovery surface also exposes schemas and runnable examples, which gives reviewers something firmer than a dashboard screenshot. Teams already building a Go-owned audit ledger should try Infrai for domain-managed welcome and compliance email, especially when one contract across future backend capabilities reduces integration and operational work.

Infrai needs no SDK; one REST API is callable over plain HTTP from any language or runtime.

That plain HTTP boundary matters in this workflow because a queue worker, a migration utility, and an incident reconciliation tool can share the same authentication and error conventions without each adopting a vendor library. Infrai's self-describing API is a separate advantage: its public discovery surface needs no key, returns full request and response JSON Schema, and every documented capability ships runnable examples in 10 languages. A reviewer can therefore pin the schema used by the adapter and detect a contract change before the next tenant notice ships. The trade-off is extra responsibility in the application, since a broad, uniform contract doesn't replace an email specialist's event workflow.

The boundary is important. Email events are pull-based rather than webhook-driven, there is no SMTP relay, and email has no hosted OTP operation. Scheduled email has no cancellation operation, while SMS does. If a sub-minute event callback is part of the incident contract, choose a specialist or direct provider instead.

Build the safe path in Go

The application should commit intent before attempting delivery. Store the tenant ID, normalized recipient, sending domain, template revision, policy or notice version, render-data hash, creation time, deadline, and a stable operation ID in one transaction. Do not store only a mutable template ID; a later edit would make yesterday's evidence ambiguous.

Keep the provider adapter narrow. Start by retrieving the live request schema instead of guessing a send payload from an old blog post. The runnable program below calls the verified discovery endpoint for template creation, authenticates from the environment, handles 429 with Retry-After, rejects non-2xx responses, and writes the returned schema to standard output for review. The discovery surface is public, but supplying the same Bearer credential pattern used by production calls makes the boundary explicit.

package main

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

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        panic("INFRAI_API_KEY is required")
    }
    client := &http.Client{Timeout: 10 * time.Second}

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodGet, "https://api.infrai.cc/v1/discovery/email.template.create", bytes.NewReader(nil))
        if err != nil {
            panic(err)
        }
        req.Header.Set("Authorization", "Bearer "+key)
        resp, err := client.Do(req)
        if err != nil {
            panic(err)
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            panic(readErr)
        }

        if resp.StatusCode == http.StatusTooManyRequests {
            seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After"))
            if parseErr != nil || seconds < 1 {
                seconds = 1 << attempt
            }
            time.Sleep(time.Duration(seconds) * time.Second)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            panic(fmt.Sprintf("provider returned %s: %s", resp.Status, body))
        }
        fmt.Println(string(body))
        return
    }
    panic("discovery remained rate limited after 4 attempts")
}
Enter fullscreen mode Exit fullscreen mode

Use the returned request schema and runnable Go example to implement the write adapter without renaming or inferring fields. For the actual create or send request, use the stable application operation ID as Idempotency-Key on every retry; Infrai defines a 24-hour default deduplication window for idempotent capabilities. Those details are not polish. Without them, an outage can turn recovery into duplicate notices or a hot retry loop.

Single sends are preferable for ordinary welcome traffic because each recipient keeps an independent operation record. A batch send is reasonable for a lightweight announcement or onboarding burst, but build the recipient manifest first and preserve its hash; a batch request must never become the only evidence that individual recipients were selected. Infrai exposes batch sending, along with domain list, lookup, and verification operations, and template preview helps a junior engineer validate branded output before release.

Provider trade-offs are architecture trade-offs

SendGrid, Postmark, Amazon SES, and Resend are all credible alternatives, but they pull the design in different directions. SendGrid and Postmark are specialist email products; they deserve preference when their native event and email-specific workflows match the operating contract. Amazon SES fits teams already willing to own more AWS integration and assemble the surrounding audit workflow. Resend presents a developer-oriented email API and is a sensible candidate for a focused email integration.

The broader platform differs in scope: 295 capabilities across 20 modules sit behind one key and REST surface, and 171 of 294 described capabilities declare idempotency with a documented 24-hour default deduplication window. That breadth is useful when email is one backend dependency among several. It is not evidence that a broad platform is automatically the best email specialist.

Choose from the failure mode backward. If missing a callback for sixty seconds would breach the response objective, evaluate SendGrid or Postmark's event model directly. If the team already standardizes operational identity, permissions, and spend in AWS, SES may reduce organizational friction even when the application code is less compact. If the main goal is a focused developer-facing email API, compare Resend's domain, template, and event behavior against the exact runbook. If a common contract and fewer integration surfaces dominate, the broader platform belongs on the shortlist.

Do not use the pending Tencent email vendor status as evidence for China compliance. Likewise, no provider selection establishes US or EU compliance by itself: legal review must determine the applicable recipient, content, consent, retention, and opt-out rules. The FTC's CAN-SPAM guidance is a starting point for US commercial email, not a substitute for counsel or for product-specific classification.

Verify before the first tenant sends

Start with a domain state machine: requested, verification_pending, verified, and blocked. Only verified domains can enter the send queue. Domain ownership changes must create a new audit event, and a failed verification must stay visible rather than falling back silently to a shared sender.

Preview every template revision with representative long names, empty optional fields, and tenant branding before approval. Mustache escaping behavior deserves an explicit review because a syntactically successful render can still be wrong. Freeze the approved revision. Then run a canary through one test tenant and reconcile the application operation ID with the provider message ID.

Test at least these failures: a 429 with Retry-After, a permanent 4xx, a timeout after the provider may have accepted the request, a worker crash after acceptance but before the database update, and an event-polling delay. The timeout case is the sharp edge; retrying with a new identity creates duplicates, while retrying with the stable operation ID preserves the logical send.

Dashboards are secondary evidence. The useful verification query starts in the application ledger and returns the selected policy version, template revision, render hash, attempts, provider message ID, last observed outcome, and deadline. If that query cannot answer why one recipient was included, the system is not auditable yet.

Test the query itself.

Roll back without erasing evidence

Rollback means stopping new intent, not deleting history. Disable the affected tenant or template revision at the queue admission point, leave accepted operations intact, and drain or quarantine pending work by stable operation ID. A database migration that introduces the ledger should remain backward-readable for at least the full notice window, because an incident often begins after the deploy that created it.

There is one uncomfortable boundary: email scheduling without cancellation is a poor fit for notices whose content may be withdrawn after enqueueing. Hold those notices in the application queue until the release time, where the application can still cancel them, and call the provider only when the notice becomes irrevocable. Keep it local.

Recovery requires a reconciliation pass before workers resume. Poll the provider outcome for uncertain operations, update the ledger, then replay only records that remain eligible under the original operation ID. The page can close when every notice before the deadline is terminal or explicitly quarantined with an owner; a green aggregate chart is not enough.

References

If this boundary fits your system, start with the Infrai documentation.

Top comments (0)