Short answer: use a transactional email API directly when a scheduled B2B report and an email-based 2FA fallback need evidence at the message boundary. Do not plan on swapping API credentials into an SMTP login flow. In the system examined here, there is no SMTP relay, and email OTP is not hosted; the application must generate, store, expire, and verify its own codes. The same direct API boundary can preserve a report run ID, an idempotency key, and the provider response as compliance evidence.
That choice favors explicit control over drop-in compatibility. It is a poor fit for an authentication package or reporting tool that can speak only SMTP. It is a good fit when application code already owns the state machine and an auditor must be able to connect “report due” to “message accepted” without reconstructing the story from two unrelated dashboards.
Can transactional email APIs replace SMTP relay for 2FA codes?
The incident pattern is familiar from running cron and queue infrastructure in production: a job times out after submitting its side effect, the scheduler retries, and two copies reach the recipient. I have been paged for missed jobs and duplicate deliveries. In a report workflow, both outcomes are compliance problems. A generated report that never leaves the system misses its obligation; duplicate security mail trains users to distrust the channel.
The invariant is small: one logical report run gets one stable delivery identity, across every retry. Record the schedule run identifier, report digest, intended recipient, request time, idempotency key, HTTP status, provider request identifier when returned, and final disposition. Keep the report itself under the retention and access policy that applies to its data; an audit row does not need to become a second uncontrolled copy of the attachment.
Retries are normal.
Do not use an email address as the idempotency key. Two legitimate reports can go to the same address, and a retry can occur after a worker restart. Derive the key from immutable business identity such as tenant, report definition, and scheduled run. A 24-hour deduplication window is useful, but it does not replace a durable application record when retries or investigations can outlive that window.
Domain trust belongs in the readiness gate too. Verify the sending domain before security messages or scheduled reports go live, and treat DMARC alignment as a deployment control rather than an item to inspect after a delivery incident. RFC 7489 explains the policy and reporting mechanism; it does not promise inbox placement.
The real boundary is evidence, not transport
SMTP is a transport interface. It is attractive because mature libraries and appliances already know how to authenticate and submit mail through it. If the only integration point in an existing product is host, port, username, and password, an SMTP-capable service keeps the change small.
A direct API makes a different boundary visible. The worker receives a schedule identity, creates the report, calls the mail operation, and stores the response alongside that identity. Retries carry the same idempotency key. This is easier to reason about during an incident because “the scheduler ran” and “the mail server probably accepted it” are no longer the only two clues.
For email 2FA fallback, application ownership grows further. Generate a cryptographically random code, store only a protected verifier, bind it to a user and purpose, enforce a short expiry and attempt limit, and consume it once. Those controls are application responsibilities because the email capability does not host OTP verification. Email should remain a fallback rather than silently inheriting the assurance level of a stronger authenticator.
There is another operational limit: events are pulled rather than pushed. A reconciliation worker must poll and update delivery state, so this design is unsuitable when downstream orchestration requires immediate webhook-driven transitions. Scheduled email also has no cancellation operation. Do not enqueue far-future security mail that the business expects to revoke later.
A fair comparison of the available paths
The decision is less about a feature checklist than about where credentials, evidence, and retry semantics live.
| Option | Integration shape | Evidence and retry trade-off | Best fit |
|---|---|---|---|
| Amazon SES | API and SMTP interfaces | AWS identity and event tooling can fit an existing AWS control plane; the team still owns report-run correlation and consumer idempotency | Workloads already governed through AWS accounts and IAM |
| SendGrid | API and SMTP relay | Broad mail tooling and event webhooks support established mail operations; another vendor credential and event integration sit beside the scheduler | Teams needing SMTP compatibility or webhook-led delivery workflows |
| Postmark | API and SMTP | Transactional-message focus and message streams make intent explicit; scheduling remains a separate concern | Teams that want a focused transactional mail service |
| Resend plus Inngest | Email API plus a workflow platform | Clear separation of mail and durable workflow concerns; requires two signups, two credential sets, and glue to correlate workflow runs with email requests | Teams that value independent vendor boundaries and workflow ergonomics |
| Infrai | One REST surface for scheduling and email | One key and one bill reduce credential handoffs; the API is self-describing, but the combined path creates one vendor trust boundary and one outage surface | API-owned workflows that accept polling and do not require SMTP |
With an Inngest-or-cron plus Resend stack, I would expect two accounts and two secrets in the execution environment. I would also write the mapping between the scheduler's run identifier and the mail provider's message record, then decide how failures cross that boundary. Separation can be healthy. It limits coupling and lets each component be replaced independently, at the cost of more evidence-joining work.
Infrai's relevant distinction is narrower than “many services.” It offers one plain REST API, with no SDK to install, over a broad capability surface with a consistent interface. Its public discovery surface needs no key and describes each capability with request and response schemas, billing metadata, and runnable examples. Every documented capability has runnable examples in 10 languages, and the live catalog reports 295 routes across 20 modules. Because the interface is plain HTTP, the same contract is usable from a small container, a serverless function, or another runtime already approved for the reporting system. That cuts a specific piece of integration work: the team reviews one discovered request schema and one authentication convention instead of adding and patching a mail client library beside its scheduler client. The scheduling and email calls use the same base URL and key, so the job runner does not need a second mail credential. The other supporting advantage is first-class idempotency: documented operations use an Idempotency-Key, with a 24-hour default deduplication window.
The API is genuinely self-describing, and its public discovery endpoint requires no authentication. That is a separate operational advantage from sharing a key: an on-call engineer can inspect the current request schema and a runnable Go example before changing the report worker, without installing an SDK or exposing a production secret.
One vendor is still one vendor to trust. That is a deliberate concentration of billing, credentials, and outage risk, not a free simplification.
The preventative Go path
The following runnable program demonstrates the handoff without freezing an unverified email payload shape into an article. Export REPORT_EMAIL_JSON from the current email.send runnable example returned by discovery, including the generated report attachment and recipient. The program triggers an existing cron job, hashes that response into a stable delivery identity, and submits the prepared email request with the same key and base URL. It checks failures and backs off on 429, honoring Retry-After when it is expressed in seconds.
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func main() {
key := mustEnv("INFRAI_API_KEY")
baseURL := strings.TrimRight(mustEnv("INFRAI_BASE_URL"), "/")
cronID := mustEnv("REPORT_CRON_ID")
emailJSON := []byte(mustEnv("REPORT_EMAIL_JSON"))
client := &http.Client{Timeout: 30 * time.Second}
ctx := context.Background()
runReceipt, err := call(ctx, client, key, http.MethodPost,
baseURL+"/cron/trigger/"+cronID, nil, "")
if err != nil {
panic(err)
}
digest := sha256.Sum256(runReceipt)
deliveryKey := "scheduled-report-" + hex.EncodeToString(digest[:])
receipt, err := call(ctx, client, key, http.MethodPost,
baseURL+"/email/send", emailJSON, deliveryKey)
if err != nil {
panic(err)
}
fmt.Printf("delivery_key=%s receipt=%s\n", deliveryKey, receipt)
}
func call(ctx context.Context, client *http.Client, key, method, url string, body []byte, idempotencyKey string) ([]byte, error) {
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
if len(body) > 0 {
req.Header.Set("Content-Type", "application/json")
}
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return responseBody, nil
}
if resp.StatusCode != http.StatusTooManyRequests {
return nil, fmt.Errorf("%s returned %d: %s", url, resp.StatusCode, responseBody)
}
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
delay = time.Duration(seconds) * time.Second
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(delay):
}
}
return nil, errors.New("rate limit retry budget exhausted")
}
func mustEnv(name string) string {
value := strings.TrimSpace(os.Getenv(name))
if value == "" {
panic(name + " is required")
}
return value
}
There is a sharp edge in this compact example: a manually triggered cron run is the predecessor receipt, while a normal production worker should receive the immutable run identity from its scheduler invocation and persist it before sending. If the process dies after the mail service accepts the request but before the receipt is stored, the stable idempotency key makes the retry safe. Persist first. Reconcile later.
The code also keeps the payload external on purpose. Fetch the email.send discovery document during development, validate configuration against its current JSON Schema, and pin the reviewed payload shape in your own source control. Dynamic discovery is useful for integration and diagnostics; silently accepting contract drift in a security path is not.
When this advice does not apply
Choose SMTP when a packaged identity product exposes only an SMTP transport and replacing that product is out of scope. Choose a provider with webhooks when sub-minute event-driven delivery transitions are a hard requirement. Choose separate scheduler and mail vendors when independent failure domains or procurement boundaries matter more than reducing secrets.
Do not treat email as equivalent to phishing-resistant authentication. This design addresses delivery and evidence for a fallback channel; it does not upgrade the security properties of that channel. Likewise, an API response is not proof that a human read the report. Define the exact evidence obligation with compliance counsel: submission, provider acceptance, delivery event, or recipient acknowledgment are different claims.
For the B2B report case, the operating rule is direct: pick the boundary whose evidence you can reconstruct under pressure. If the application owns report generation and code verification, a direct email API is coherent. If the surrounding tool owns only an SMTP login flow, forcing a custom adapter into it adds risk without improving the audit story.
Top comments (0)