The page says signup_email_verification_completion_below_slo. On-call can see that 1,842 email 2FA challenges were created in 15 minutes, while only 1,603 were completed before expiry; the transactional API replaced the SMTP relay for this gaming signup path, so there is no mail-server login failure to find.
TL;DR: use a direct transactional email API for the email fallback, and keep challenge generation, expiry, single-use verification, and compliance evidence in the game account service. An API can replace the sending leg of an SMTP login flow, but it cannot be dropped into software that accepts only SMTP credentials. Alert on the end-to-end verification outcome, use provider handoff as an earlier diagnostic signal, and verify the sending domain before production security mail.
That separation is the least complex design that answers both the delivery and audit questions. It also prevents an attractive but wrong conclusion: a successful provider handoff is not proof that a player controlled the mailbox.
Can transactional email replace an SMTP relay for 2FA codes?
Start at the page, not at the vendor dashboard. The primary symptom is a player outcome: the proportion of issued email challenges completed before the application's expiry policy has fallen below its service-level objective. The alert should carry the affected game or region, the evaluation window, created and completed counts, and links to internal traces keyed by a random challenge ID. It should never carry the code, the complete verification URL, or the message body.
The response path then runs backward:
- Check whether challenge creation changed while completion fell.
- Split incomplete challenges into provider handoff failures, accepted handoffs, and challenges still inside the expiry window.
- Compare the handoff error ratio and latency distribution with their recent baselines.
- Inspect domain verification and suppression state through the chosen provider's documented controls.
Those 1,842 and 1,603 values are example alert payloads, not a benchmark or a proposed SLO. A real objective needs the game's signup volume, expiry period, delayed-delivery tolerance, and error budget. At 20 challenges per minute, a five-minute window contains only about 100 attempts and a handful of outcomes can swing a percentage sharply; at 20,000 per minute, waiting fifteen minutes may waste too much of the error budget. Capacity planning changes the window.
Short windows page quickly. They also page noisily.
For compliance evidence, record state transitions rather than prose logs: challenge_created, email_handoff_accepted, email_handoff_failed, challenge_completed, and challenge_expired. Each record needs a challenge ID, event time, provider identifier where one exists, and a controlled reason category. The completion event is the evidence of successful verification. The accepted event proves only that the sending service accepted a request.
Instrument the signal before changing the threshold
The following Go program is deliberately provider-neutral. It is runnable with Go 1.22 or later, creates structured audit events, hashes the secret, enforces a ten-minute example expiry, and computes completion and handoff-failure ratios from the resulting states. The ten-minute value is application policy in this example, not a provider default.
package main
import (
"bytes"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
type State string
const (
Created State = "challenge_created"
Accepted State = "email_handoff_accepted"
Failed State = "email_handoff_failed"
Complete State = "challenge_completed"
)
type Event struct {
State State `json:"state"`
ChallengeID string `json:"challenge_id"`
At time.Time `json:"at"`
MessageID string `json:"message_id,omitempty"`
Reason string `json:"reason,omitempty"`
}
type Challenge struct {
Digest string
ExpiresAt time.Time
Used bool
}
type Mailer interface {
Send(challengeID, recipient, link string) (string, error)
}
type InfraiMailer struct {
Origin string
Key string
Body []byte
}
func (m InfraiMailer) Send(challengeID, recipient, link string) (string, error) {
if m.Origin == "" || m.Key == "" || len(m.Body) == 0 {
return "", errors.New("set INFRAI_API_ORIGIN, INFRAI_API_KEY, and INFRAI_EMAIL_BODY")
}
client := &http.Client{Timeout: 15 * time.Second}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodPost, m.Origin+"/v1/email/send", bytes.NewReader(m.Body))
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+m.Key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", challengeID)
resp, err := client.Do(req)
if err != nil {
return "", err
}
responseBody, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return "", readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return "", nil
}
if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
return "", fmt.Errorf("email API status %d: %s", resp.StatusCode, responseBody)
}
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
}
return "", errors.New("email retry limit reached")
}
type Service struct {
mailer Mailer
challenges map[string]*Challenge
events []Event
now func() time.Time
}
func randomText(bytes int) (string, error) {
b := make([]byte, bytes)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func digest(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func (s *Service) record(event Event) {
s.events = append(s.events, event)
b, _ := json.Marshal(event)
fmt.Println(string(b))
}
func (s *Service) Begin(recipient string) (string, string, error) {
id, err := randomText(18)
if err != nil {
return "", "", err
}
token, err := randomText(32)
if err != nil {
return "", "", err
}
now := s.now()
s.challenges[id] = &Challenge{Digest: digest(token), ExpiresAt: now.Add(10 * time.Minute)}
s.record(Event{State: Created, ChallengeID: id, At: now})
link := "https://accounts.example.test/verify?challenge=" + id + "&token=" + token
messageID, err := s.mailer.Send(id, recipient, link)
if err != nil {
s.record(Event{State: Failed, ChallengeID: id, At: s.now(), Reason: "send_error"})
return "", "", err
}
s.record(Event{State: Accepted, ChallengeID: id, At: s.now(), MessageID: messageID})
return id, token, nil
}
func (s *Service) Verify(id, token string) error {
c, ok := s.challenges[id]
if !ok || c.Used || s.now().After(c.ExpiresAt) || digest(token) != c.Digest {
return errors.New("invalid, expired, or used challenge")
}
c.Used = true
s.record(Event{State: Complete, ChallengeID: id, At: s.now()})
return nil
}
func ratios(events []Event) (completion, handoffFailure float64) {
counts := map[State]int{}
for _, event := range events {
counts[event.State]++
}
if counts[Created] > 0 {
completion = float64(counts[Complete]) / float64(counts[Created])
handoffFailure = float64(counts[Failed]) / float64(counts[Created])
}
return completion, handoffFailure
}
func main() {
mailer := InfraiMailer{
Origin: os.Getenv("INFRAI_API_ORIGIN"),
Key: os.Getenv("INFRAI_API_KEY"),
Body: []byte(os.Getenv("INFRAI_EMAIL_BODY")),
}
s := &Service{mailer: mailer, challenges: map[string]*Challenge{}, now: time.Now}
id, token, err := s.Begin("player@example.test")
if err != nil {
panic(err)
}
if err := s.Verify(id, token); err != nil {
panic(err)
}
completion, failures := ratios(s.events)
fmt.Printf("completion=%.2f handoff_failure=%.2f\n", completion, failures)
}
Run it as a single file:
go run verification.go
Set INFRAI_API_ORIGIN to the documented API origin, and set INFRAI_EMAIL_BODY to a request validated against the public discovery schema for email.send; accepting that JSON as input keeps the example runnable without guessing fields that are not established here. The adapter calls the verified /v1/email/send route, sets the method explicitly, reads the API key from the environment, surfaces non-success bodies, reuses the challenge ID for idempotency, and honors a numeric Retry-After value on HTTP 429 before falling back to exponential delay. Persist challenge state in a transactional store, and make verification an atomic compare-and-update so two requests cannot redeem one token.
There is another boundary that monitoring cannot repair. Because the email capability discussed here does not host OTP, the application owns generation and verification. Issue and verification attempt limits also belong in the application, as do geographic controls and country-based spend circuit breakers if SMS is another fallback. Email and SMS event access is pull-based here rather than webhook-driven, so a cross-channel orchestrator must budget for polling delay instead of claiming real-time state.
Replace SMTP only where the application has an API boundary
An auth package that exposes SendVerification(ctx, recipient, link) can use an HTTP adapter. A package that exposes only SMTP host, port, username, and password cannot. No arrangement of API keys inside those fields changes that fact; either add a custom transport, move delivery behind a service you control, or select a provider with SMTP relay support.
Infrai is one direct-API option in the first case, not an SMTP replacement for the second. Its relevant advantage is mundane and useful: it is a plain REST API, so any runtime capable of an authenticated HTTP request can call it without installing or maintaining a vendor SDK. Infrai uses one key for 295 routes across 20 modules and produces one bill for those capabilities. That avoids accumulating dozens of separate credentials and invoices if the same platform team adopts multiple backend capabilities; for this signup-only decision, the breadth matters only if consolidation is actually on the roadmap. A separate advantage matters during control review: its public discovery surface needs no key and returns the full request and response schemas, billing information, and runnable examples for a capability, which gives the adapter owner a machine-readable contract to compare with the evidence pipeline. Its documented platform convention also specifies idempotency with a 24-hour default deduplication window. The trade-off is clear: integration code is required, email OTP remains application-owned, and event consumption is by polling. Verify the sending domain before sending account-security messages.
Scheduled verification mail is a poor fit. The capability supports a scheduled send but has no email cancellation route, so a message can outlive an account-state change that should have invalidated it. Send verification mail immediately and let the application's challenge expiry decide validity. SMS cancellation support does not imply equivalent email behavior.
For an SMTP-bound legacy tool, a local SMTP-to-API bridge can appear tempting. It also creates a security-sensitive relay that must be authenticated, patched, capacity-planned, observed, and prevented from becoming an open relay. A large estate may accept that ownership cost. One game signup service usually should not.
Compare evidence paths, not feature counts
Postmark, SendGrid, and Amazon SES all document both API and SMTP sending paths, which makes them candidates when migration must preserve an SMTP-only caller. Infrai is a candidate when the caller can make a direct API integration and the team values a plain HTTP contract over SMTP compatibility. Mailgun also documents SMTP and HTTP API sending, adding another viable dual-transport choice.
| Option | Documented transport fit | Compliance-evidence consequence | Operational boundary |
|---|---|---|---|
| Postmark | API and SMTP | Provider records can support a handoff trail; application verification remains separate | Useful for an SMTP-only caller or a custom adapter |
| SendGrid | API and SMTP | Delivery activity and application completion must remain distinct evidence | Supports staged migration between transports |
| Amazon SES | API and SMTP | AWS identity and logging controls may align with an existing AWS control plane | SMTP credentials and API authorization are different integration work |
| Mailgun | API and SMTP | Provider events do not prove challenge completion | Fits teams that require both transport choices |
| Infrai | Direct REST API; no SMTP relay | Pull provider state into the audit trail; keep OTP evidence in the application | No SDK is required, but an SMTP-only product cannot use it directly |
This is not a deliverability ranking. No comparative inbox-placement, latency, uptime, or cost measurement supports one. Run a controlled evaluation using the game's real sending domains and destinations, and review suppression behavior, domain authentication, regional processing, retention, access control, exportability, and contractual terms. A pending domestic email vendor integration is not evidence for compliance in China; readiness, data handling, and the applicable legal requirements need their own review.
The buy-versus-build decision is narrower than it first appears:
| Decision | Buy a provider transport | Build in the account service |
|---|---|---|
| Mail submission and provider handoff | Yes | Adapter and error mapping only |
| Challenge generation, hashing, expiry, and one-time redemption | No | Yes |
| Domain authentication controls | Provider-guided | DNS ownership and approval process |
| Signup completion SLO and audit correlation | No | Yes |
| SMTP compatibility | Choose a provider that offers it | Do not emulate it casually |
The provider should carry mail. The account service should own what “verified” means.
Close the gap without buying alert fatigue
The earlier signal is the ratio of handoff failures to created challenges, partitioned by provider and sending domain. It can fire before enough challenges expire to depress the completion SLO, but it is diagnostic rather than authoritative: accepted mail can still arrive late or never be acted upon, while a brief handoff error spike may be absorbed by retry and never affect players. Track both signals and connect them with the same challenge ID.
Before enabling a page, replay known states in a non-production environment: one accepted-and-completed challenge, one provider rejection, one expiry, one duplicate verification attempt, and one ambiguous timeout followed by an idempotent retry. Confirm that the audit store contains no raw token or full link. Confirm that domain verification is complete. Confirm that a consumed token stays consumed.
Then size the alert from the error budget. A page should represent a rate of failed player outcomes that requires human action; a ticket or dashboard can carry lower-volume drift. Require a minimum event count, use a multi-window burn-rate policy where traffic supports it, and partition only on dimensions with enough samples. Do not copy the illustrative counts from the opening into production.
The false-positive cost is not abstract. Every noisy page trains on-call to discount the next one, and every overly sensitive automated failover can issue duplicate codes across channels, confuse players, and muddy the evidence chain. Set the threshold against the signup SLO, validate it with real traffic distributions, and keep provider handoff alerts subordinate to the outcome they are meant to protect.
Further reading
References:
- DMARC, RFC 7489: https://datatracker.ietf.org/doc/html/rfc7489
- Postmark SMTP documentation: https://postmarkapp.com/developer/user-guide/send-email-with-smtp
- SendGrid SMTP integration: https://www.twilio.com/docs/sendgrid/for-developers/sending-email/integrating-with-the-smtp-api
- Amazon SES sending methods: https://docs.aws.amazon.com/ses/latest/dg/send-email.html
- Mailgun sending documentation: https://documentation.mailgun.com/docs/mailgun/user-manual/sending-messages/
- NIST Digital Identity Guidelines, Authentication and Authenticator Management: https://pages.nist.gov/800-63-4/sp800-63b.html
Top comments (0)