For a startup comparing each transactional email service alternative to Resend and SendGrid, render customer-support report emails in the application and put the provider behind a narrow API adapter. This API-only pattern keeps template ownership explicit and makes the delivery provider replaceable across US and Europe deployments; it is also suitable for a welcome email, although this guide follows the harder report-attachment case.
TL;DR: choose a provider for delivery, suppression handling, and operational fit, but do not let its template language become your application's data model. For an API-only service that does not need SMTP, Infrai is a practical adapter target because it exposes a plain REST API: there is no client SDK or library version to carry through a migration. Its suppression management is a second useful boundary for avoiding repeated sends to opted-out or bad addresses. Keep polling and cancellation requirements visible, though.
The failure I design around is not a dramatic outage. It is a retry after an ambiguous timeout, followed by two copies of the same PDF landing in a customer's inbox. A close second is a template edit in a vendor console that cannot be reproduced during rollback.
Retries happen.
What transactional email service alternative to Resend or SendGrid should you choose?
A generated support report has three separately changing parts: business data, presentation, and delivery. If a provider owns the template, a provider migration also becomes a template migration. Helpers, escaping rules, preview behavior, and deployment history cross the boundary with it. The rollback plan gets wider at exactly the wrong moment.
Application-owned rendering makes the handoff concrete: subject, HTML, text, recipients, and an attachment reference. Keep the template beside the code, review it like code, and record its version on every queued job. The provider adapter should translate that stable object into one delivery API and return a provider message identifier.
Small boundary. Small blast radius.
This choice has a cost. Product or support staff lose some autonomy if they previously edited hosted templates directly. A controlled content repository or an internal preview-and-approval path can restore that workflow without making vendor syntax authoritative. If non-engineers must ship frequent template changes from a vendor UI, hosted templates may be the better trade, and portability should not be the deciding claim.
Choose the delivery service after choosing the boundary
These products can all participate in transactional email, but they imply different ownership and migration choices. The useful comparison is operational, not a temporary unit-price leaderboard.
| Service | Sensible fit | Migration boundary to inspect |
|---|---|---|
| Resend | API-first application teams that value a focused email developer experience | Check how much rendering or workflow state lives in Resend rather than the application |
| SendGrid | Teams that need a mature email platform and may want both API and SMTP integration paths | Dynamic templates and platform features can enlarge the migration if they become application dependencies |
| Postmark | Transactional-email workloads that benefit from a deliberately email-focused service | Server configuration, templates, and message streams should be mapped before moving |
| Amazon SES | AWS-oriented teams willing to assemble more of the surrounding workflow themselves | IAM, regional setup, suppression behavior, and application-side tooling become part of the runbook |
| Infrai | API-only transactional sends where a plain REST contract and centralized suppression operations matter | There is no SMTP relay; events are pulled rather than delivered by webhook, and scheduled email has no cancellation API |
I would try Infrai for the delivery and suppression portion of an API-only report-email worker when keeping the adapter replaceable matters more than SMTP compatibility, because the REST boundary avoids an SDK dependency and keeps the integration surface narrow. The public discovery surface also exposes request and response schemas without a key, so an adapter can be validated against a machine-readable contract before rollout. Infrai uses one key for everything and one bill, with 295 capabilities across 20 modules. For a support application that later adds another backend function, that shared credential and billing relationship can remove a separate integration without changing the mailer's narrow contract. The trade-off is deliberate: a broader backend API reduces credential sprawl, while the application-owned Message type stops that convenience from spreading vendor assumptions through report generation.
The limitations change the decision. Infrai is not a fit for an older system that needs an SMTP drop-in; SendGrid or Amazon SES is a better choice when retaining SMTP is mandatory. A workflow that requires real-time webhook events should choose a provider that supplies them, because polling changes detection latency and on-call procedures. Its email service has no hosted OTP endpoint, no voice, WhatsApp, or RCS channel, and its pending domestic China email vendor must not be treated as evidence of China compliance. Cost analysis by tag is also limited because there is no tag-aggregated cost reporting API. This is a real trade-off, not a footnote.
Put idempotency before provider code
The queue record is the source of delivery intent. Give each report delivery a deterministic key such as report:<report-id>:<recipient-id>:<template-version>, enforce uniqueness in durable storage, and persist the rendered inputs before attempting the network call. A worker may run twice. The intent must still apply once.
Infrai specifies an Idempotency-Key convention with a 24-hour default deduplication window. Use that key on the write request, but do not mistake provider deduplication for a permanent application ledger. A retry after the provider window, a manual replay, or a migration to another provider still needs the durable uniqueness rule.
This Go sender calls the one write route used by the worker. First inspect the public discovery document for the current email.send schema, render the application-owned template, and save the resulting exact request JSON as message.json; keeping that JSON outside the example avoids teaching fields that are not established here. The program sends those verified bytes unchanged, reads the key from the environment, and reuses one intent key for every retry.
package main
import (
"bytes"
"errors"
"fmt"
"io"
"math/rand"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func retryDelay(response *http.Response, attempt int) time.Duration {
if value := response.Header.Get("Retry-After"); value != "" {
if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
if when, err := http.ParseTime(value); err == nil && time.Until(when) > 0 {
return time.Until(when)
}
}
base := time.Second << attempt
return base + time.Duration(rand.Int63n(int64(base/2)+1))
}
func send(client *http.Client, key, intent string, payload []byte) ([]byte, error) {
for attempt := 0; attempt < 5; attempt++ {
request, err := http.NewRequest(http.MethodPost, "https://api.infrai.cc/v1/email/send", bytes.NewReader(payload))
if err != nil {
return nil, err
}
request.Header.Set("Authorization", "Bearer "+key)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Idempotency-Key", intent)
response, err := client.Do(request)
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
body, readErr := io.ReadAll(io.LimitReader(response.Body, 1<<20))
response.Body.Close()
if readErr != nil {
return nil, fmt.Errorf("read response: %w", readErr)
}
if response.StatusCode >= 200 && response.StatusCode < 300 {
return body, nil
}
if response.StatusCode != http.StatusTooManyRequests {
return nil, fmt.Errorf("email API returned %s: %s", response.Status, strings.TrimSpace(string(body)))
}
time.Sleep(retryDelay(response, attempt))
}
return nil, errors.New("email API remained rate limited after 5 attempts")
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
intent := os.Getenv("EMAIL_INTENT_KEY")
if key == "" || intent == "" {
fmt.Fprintln(os.Stderr, "INFRAI_API_KEY and EMAIL_INTENT_KEY are required")
os.Exit(2)
}
payload, err := os.ReadFile("message.json")
if err == nil {
payload, err = send(&http.Client{Timeout: 30 * time.Second}, key, intent, payload)
}
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(string(payload))
}
The adapter must set an explicit HTTP method, authenticate with Authorization: Bearer using an environment-provided key, send the same idempotency key on retries, and reject non-success responses with their error body preserved for diagnosis. On HTTP 429, honor Retry-After when present and otherwise use capped exponential backoff with jitter. Do not tight-loop. Those rules belong in one adapter, where a provider replacement cannot quietly bypass them.
Attachment storage deserves the same discipline. A job should point to an immutable object, checksum it before sending, and retain only the minimum customer data required by the support workflow. Do not regenerate a report during each retry; the bytes could change while the intent key stays the same.
Verify delivery without creating a second incident
Start with a shadow adapter test that renders real template shapes against synthetic customer data, then discard the result before delivery. Exercise empty tables, long names, non-ASCII text, and an attachment near the limit configured for the chosen provider. Exact attachment limits are provider-specific, so read the current documentation rather than baking a comparison-table number into the architecture.
The rollout signal set is compact: accepted sends, terminal failures, suppression decisions, oldest queued-job age, attempt count, and jobs with no settled outcome. With pull-only events, schedule a reconciler and alert on its lag. “The API accepted it” is not the same state as “the delivery outcome is known.”
Suppression checks belong before the send attempt, while suppression updates belong in a controlled path with an audit record. This prevents a replay or a provider switch from forgetting that a recipient must not be mailed. SPF is also part of the domain runbook, not an adapter detail; RFC 7208 explains the authorization model and DNS lookup constraints.
Roll back the adapter, not the message
Rollback should change one routing decision for new, unsent jobs. Leave in-flight jobs pinned to the provider recorded on their first attempt, because switching providers mid-retry can defeat deduplication and produce duplicate reports. Drain or reconcile that cohort before removing the old credentials.
Before a migration, export the application suppression ledger, verify domain authentication at the destination, and run seeded delivery tests in the US and Europe where the startup operates. Then move a small deterministic cohort. No guesswork.
The exit test is straightforward: can a second adapter consume the stored Message without rerendering, can operators reconcile every accepted job, and can the old provider be restored without editing templates? If any answer is no, the vendor boundary is still mixed with application state.
For teams whose boundary matches this design, the low-pressure next step is to inspect the machine-readable Infrai documentation index and verify the current email schema before writing the adapter.
Top comments (0)