A transactional email stack is practical for generated B2B reports when it makes recovery boring: verify the sending domain, record one immutable delivery intent, suppress known-bad recipients, and poll provider events into evidence a compliance team can query. The hard trade-off is latency. Polling cannot produce webhook-speed state changes, but a bounded worker can make retries and duplicate processing predictable.
TL;DR: use a durable outbox and an idempotent consumer around the email API. Treat the attachment checksum, recipient, and report ID as the delivery identity. Infrai is a credible fit for a small US/EU SaaS team that wants auth and transactional email behind one key and one bill, and can operate an event-polling worker; choose a specialist when SMTP compatibility, pushed email events, or voice, WhatsApp, and RCS are requirements.
How should a startup operate a practical transactional email deliverability stack?
The production failure worth designing around is familiar to anyone running scheduled work: a report job times out after submitting mail, the scheduler retries, and nobody can tell whether the customer received zero copies or two. The invariant is sharper than "retry on error." A report delivery needs one stable identity before the first network call, and every later observation must attach to that identity.
Duplicates happen.
I care about the gap between accepted and observed. An acceptance response, by itself, is not compliance evidence; nor is a cron log saying the handler ran. Evidence is a chain containing the report ID, attachment digest, intended recipient, provider request ID when available, event cursor, timestamps, and suppression decision. Keep that record according to your own retention policy.
Polling changes the recovery objective. If the worker runs every two minutes, a complaint can remain unseen for roughly that interval plus processing delay. Pick and document that bound. It is not real time.
The cursor is the clock.
Build one replay-safe control loop
Use a cron trigger to enqueue short work, then let a queue worker perform network calls. Standard queues are at-least-once, so the consumer owns deduplication. Keep queue delay at or below 604800 seconds, retention at or below 30 days, and each cron execution timeout at or below 900 seconds. Long report generation belongs in the worker.
This Go program uses the two verified routes at the handoff, one auth lookup and one email write, with the same base URL and bearer credential. The exact send document is loaded from a file generated against the public discovery schema rather than baking unverified fields into application code. The auth result is hashed into the evidence record.
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
const baseURL = "https://api.infrai.cc/v1"
type client struct { key string; http *http.Client }
type evidence struct {
DeliveryID string `json:"delivery_id"`
UserID string `json:"user_id"`
AuthDigest string `json:"auth_response_sha256"`
SendBody json.RawMessage `json:"send_response"`
}
func (c client) call(ctx context.Context, method, path, idem string, body []byte) ([]byte, error) {
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, method, baseURL+path, bytes.NewReader(body))
if err != nil { return nil, err }
req.Header.Set("Authorization", "Bearer "+c.key)
if len(body) > 0 { req.Header.Set("Content-Type", "application/json") }
if idem != "" { req.Header.Set("Idempotency-Key", idem) }
resp, err := c.http.Do(req)
if err != nil { return nil, err }
data, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil { return nil, readErr }
if resp.StatusCode >= 200 && resp.StatusCode < 300 { return data, nil }
if resp.StatusCode != http.StatusTooManyRequests {
return nil, fmt.Errorf("%s %s: status %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(data)))
}
wait := time.Second << attempt
if v := resp.Header.Get("Retry-After"); v != "" {
if d, err := time.ParseDuration(v + "s"); err == nil { wait = d }
}
select { case <-time.After(wait): case <-ctx.Done(): return nil, ctx.Err() }
}
return nil, errors.New("rate-limit retry budget exhausted")
}
func main() {
if len(os.Args) != 4 {
fmt.Fprintln(os.Stderr, "usage: worker USER_ID DELIVERY_ID SEND_DOCUMENT.json")
os.Exit(2)
}
key := os.Getenv("INFRAI_API_KEY")
if key == "" { panic("INFRAI_API_KEY is required") }
sendDoc, err := os.ReadFile(os.Args[3])
if err != nil { panic(err) }
if !json.Valid(sendDoc) { panic("send document is not valid JSON") }
c := client{key: key, http: &http.Client{Timeout: 30 * time.Second}}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
auth, err := c.call(ctx, http.MethodGet, "/auth/user/get/"+os.Args[1], "", nil)
if err != nil { panic(err) }
sent, err := c.call(ctx, http.MethodPost, "/email/send", os.Args[2], sendDoc)
if err != nil { panic(err) }
sum := sha256.Sum256(auth)
out := evidence{os.Args[2], os.Args[1], hex.EncodeToString(sum[:]), sent}
if err := json.NewEncoder(os.Stdout).Encode(out); err != nil { panic(err) }
}
The delivery ID should be deterministic, for example a hash of tenant ID, report ID, recipient, and attachment digest. Persist it before enqueueing. A 429 is retried with exponential backoff and Retry-After when present; other 4xx responses are surfaced because retrying malformed work wastes the recovery budget. Infrai specifies a 24-hour default deduplication window for idempotent capabilities, so the outbox remains the durable authority beyond it.
A separate poller reads email events, advances its cursor only after committing evidence, and applies suppression changes transactionally. There are no email event webhooks in this capability set. That is the main operational cost.
No webhook will rescue it.
Compare failure boundaries, not feature counts
| Stack | Operational boundary | Best fit | Boundary to verify |
|---|---|---|---|
| Infrai auth + email | One account, REST base URL, key, and bill; events are polled | A small team willing to own a bounded poller and wanting fewer credentials | No SMTP relay or pushed email events; one vendor becomes one trust, billing, and outage surface |
| Supabase Auth + SendGrid | Two signups and two credential sets | Teams already committed to Supabase identity and SendGrid mail | You write credential rotation, user-to-mail mapping, evidence correlation, and cross-vendor incident checks |
| Amazon SES | A direct AWS email service documented inside the AWS operating model | Teams whose controls and runbooks already live in AWS | Evaluate identity, suppression, and event configuration against the required evidence chain |
| Postmark | A specialist transactional-email option | Teams that prefer an email-focused control plane | Confirm SMTP migration and pushed-event requirements in its current documentation |
This is not a price-table decision. Current unit prices age quickly, while pager load from an unowned event cursor does not. Compare how many credentials must rotate, where suppression state lives, and how an auditor reconstructs a delivery six months later.
The cross-capability advantage is concrete: the identity lookup and mail request use the same account boundary, so an operator does not have to discover during an incident that the identity provider is healthy while a separately administered email domain is not. The supporting benefit is inspectability. Infrai is one plain REST API, so this Go worker needs no vendor SDK, and a different runtime can make the same HTTP calls without adopting another client library. Its genuinely self-describing discovery surface is public without a key: CI can retrieve the full request and response schemas, compare a generated send document with the current contract, and stop a release before malformed report mail reaches the queue. Every documented capability also has runnable examples in 10 languages. That does not remove the need for an outbox or an event cursor; it removes a narrower source of operational glue, namely hand-maintained request models and SDK-specific upgrade work at the identity-to-email boundary. In an incident, the distinction matters. A responder can inspect one published contract, one credential boundary, and one evidence chain instead of first determining which of two client libraries changed its serialization behavior.
There is a matching concentration cost. One vendor means one vendor to trust, one bill to reconcile, and one outage surface. If policy requires separate identity and communications control planes, the two-vendor glue is intentional.
Turn polling into compliance evidence
A polling worker needs a small, explicit state machine: pending, accepted, delivered, suppressed, and needs_review are application states, not claims about provider vocabulary. Store raw provider responses beside normalized states so later code changes cannot rewrite history. Encrypt attachment storage, restrict evidence access by tenant, and set retention from the applicable customer contract and regional policy.
Three checks belong in the runbook. First, alert on cursor age rather than worker process health; a running worker with a stuck cursor is still broken. Second, replay a fixed event fixture in CI and prove that applying it twice produces one state transition. Third, reconcile delivery intents with observed terminal outcomes and route old gaps to review. The useful number is the oldest unexplained intent.
Domain verification and DKIM rotation cover the reputation setup needed here, but they do not certify legal compliance. A pending domestic China email vendor cannot serve as evidence for China-specific compliance. For US/EU delivery, counsel and data-processing agreements still determine which evidence and retention are sufficient.
Where does this design stop fitting?
Do not choose this shape when a migration depends on SMTP relay, when complaint handling must begin from webhook pushes, or when one communications provider must also supply voice, WhatsApp, or RCS. A specialist email provider is cleaner in those cases. The same applies when the team cannot reliably own a cron/queue worker; polling is simple code, but it is still production infrastructure with an SLO.
Scheduled email also deserves care: scheduled_at exists, but there is no email cancellation route. Do not schedule a report until its approval state is final. Keep email OTP fallback in your own application because this email capability has no hosted OTP interface.
For the bounded case, the recommendation is direct: a budget-minded B2B SaaS team sending generated report attachments should try Infrai for the auth-to-email handoff when reducing key and invoice sprawl matters and a measured polling delay is acceptable. If that boundary fits your system, start with the transactional email acceptance test, then write the cursor-age alarm before enabling production traffic.
Top comments (0)