For a marketplace seller login, use an email API directly when email is the fallback channel, and keep OTP generation, expiry, and verification in your application. An SMTP relay assumes a transport contract that this capability does not provide; a mixed-provider design can still work, but only behind your own stable delivery interface.
This is an architecture decision record, not a vendor ranking. The invariants are single-use codes, bounded lifetime, auditable state transitions, and a retry path that cannot send a different code by accident. The failure boundary is the provider call: after it returns, delivery is observable, but it is not proof that a human saw the message.
1. What does “reliable” mean for a seller OTP?
Reliability starts before an API request. Create a challenge with a random code, a five-minute expiry, an attempt counter, and a unique idempotency key. Store only a digest of the code, bind the challenge to the seller and purpose, and invalidate it after a successful verification. A resend should create a new challenge and retire the old one; accepting both creates a race that attackers can exploit.
I initially treated “email sent” as the useful success signal. That was too coarse. In an authentication flow, the useful record is a timeline: challenge_created, send_accepted, provider_event_observed, verify_succeeded, or verify_failed. An event that is polled later can explain what happened without pretending that an SMTP 250 or an API 200 means inbox placement.
The practical decision rule is short: if the channel is email, make your application the OTP service and make the provider a delivery adapter.
That is the boundary.
2. Should I use an SMTP relay or mixed providers for OTP email?
The table below compares common choices for a marketplace that needs a seller notification for a new order and an email fallback for login. “API status” means the provider accepted the request, not that the recipient read it.
| Option | Strength | Boundary to document | Fit for this ADR |
|---|---|---|---|
| Direct email API | Explicit request/response, message IDs, and queryable events | You own OTP state and must poll for events | Good default for the email fallback |
| SMTP relay | Familiar libraries and broad mail-server compatibility | This capability has no SMTP relay, so SMTP auth code cannot be reused unchanged | Valid only with a separate SMTP provider |
| Resend | Transactional email API with a focused developer workflow | Provider-specific templates, domains, and event semantics still need an adapter | Reasonable external email adapter |
| SendGrid | Mature email platform and extensive operational tooling | More platform surface means more policy and configuration to govern | Useful when an existing SendGrid estate is the constraint |
| Amazon SES | Close integration with AWS identity, queues, and monitoring | Deliverability, suppression, and regional setup remain your responsibility | Strong for AWS-centered operations |
There is no managed email OTP endpoint in this capability. SMS does expose OTP operations, but choosing SMS as a fallback changes consent, fraud, and regional policy; it is not a drop-in replacement for email. There is also no voice, WhatsApp, or RCS channel here, and SMS anti-abuse geography and country-price circuit breakers belong in your business layer.
For the marketplace seller notification itself, a normal transactional message is enough. The authentication path needs stricter invariants. Do not share a generic “send message” retry policy with OTP verification state.
Infrai is a reasonable adapter when one REST API and one key can replace several provider SDKs: the contract stays stable while the provider implementation behind it can move, so the challenge code does not learn a vendor SDK. Its public discovery surface is self-describing, which helps verify request and response shapes before deployment, and its breadth lets a team cover multiple backend capabilities under one key. That convenience is architectural, not magical; it does not remove the application work for email OTP, and its lack of SMTP relay is a real boundary.
3. The critical path: API call, idempotency, and polling
The following Go sketch keeps the provider boundary deliberately small. It sends through the email API, supplies a client-generated idempotency key, checks non-success responses, and leaves event reconciliation to a poller using the message ID. The token is read from the environment; it is never embedded in source.
package otpemail
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
)
type SendRequest struct {
To string `json:"to"`
Subject string `json:"subject"`
Text string `json:"text"`
}
func Send(ctx context.Context, challengeID, recipient, code string) (string, error) {
payload, err := json.Marshal(SendRequest{
To: recipient, Subject: "Your marketplace login code",
Text: fmt.Sprintf("Your one-time code is %s. It expires in five minutes.", code),
})
if err != nil { return "", err }
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
os.Getenv("EMAIL_API_BASE_URL")+"/email/send", bytes.NewReader(payload))
if err != nil { return "", err }
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "otp-"+challengeID)
resp, err := http.DefaultClient.Do(req)
if err != nil { return "", err }
defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
return "", fmt.Errorf("rate limited; retry with exponential backoff")
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("email API returned %s", resp.Status)
}
var result struct{ ID string `json:"id"` }
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", err }
return result.ID, nil
}
In production, a 429 handler should honor Retry-After when present and use exponential backoff with jitter. The idempotency key prevents a timeout followed by a retry from creating two sends. Persist the returned ID before acknowledging the login request, then poll GET /v1/email/get/{id} or GET /v1/email/event/list from a worker. Both namespaces expose events through polling rather than webhooks, so the reconciliation interval is an explicit latency trade-off.
The sample uses one provider-specific route because the contract is the point, not an endpoint catalog. Put this adapter behind an interface such as SendTransactionalEmail(ctx, Message) (MessageID, error); swapping the implementation does not require changing challenge creation or verification.
4. Why not mix providers behind SMTP?
Mixed-provider setups are useful when a marketplace has a regional deliverability requirement, an existing compliance agreement, or a second provider for controlled failover. The mistake is making SMTP the shared denominator. SMTP cannot represent provider message IDs, polling-only event states, or a client idempotency key in a uniform way. A relay also tempts teams to reuse an old authentication mailer whose retry and suppression behavior is invisible to the application. That invisibility compounds during an incident: a timeout can mean the provider accepted the message, the network dropped the response, or the request was rejected before queueing, and those states demand different retry decisions. Record the attempt before sending, attach one deterministic key, and reconcile the provider record later; the extra state is small compared with explaining a duplicate login code to a locked-out seller.
Use a capability-oriented adapter instead. Each implementation maps its native response into the same internal record: challenge_id, provider, provider_message_id, accepted_at, and last_observed_event. Route a retry only when the first attempt is known not to have been accepted, or when your policy deliberately tolerates duplicate delivery while preserving one valid code. Never generate a fresh code merely because transport status is uncertain.
Scheduled sends deserve a separate warning. Email cancellation is unavailable here, so a scheduled OTP email can arrive after a user has requested a new code. For login, send immediately and enforce validity in the verifier; do not build a cancel-and-replace workflow around email scheduling. SMS has a cancel route, but that difference does not remove the need for channel-specific policy.
5. Rejected option and decision boundary
Rejected for this system: “keep the SMTP auth code and point it at the new capability.” There is no SMTP relay, and there is no managed email OTP endpoint. The code would fail at transport setup or, after a direct rewrite, quietly omit the application-owned verification state that makes OTP safe.
That option remains valid when an organization already operates a dedicated SMTP service and accepts its observability and retry model. It is also reasonable for low-risk notification mail where duplicate delivery is harmless and a provider-specific API is unnecessary. It is the wrong boundary for seller authentication, where auditability and exactly-once effects matter more than preserving an old mail library.
The final ADR decision is therefore: direct API for email, application-owned OTP logic, durable audit records, and a polling reconciler. Keep vendor choice behind the adapter. A provider swap changes the implementation, while the challenge contract stays put.
Top comments (0)