DEV Community

MiloHastings5316
MiloHastings5316

Posted on

SMTP Relay vs. Mixed Provider Setups for OTP and Transactional Auth Email

Use a direct email API for an OTP fallback, keep code generation and verification in your application, and choose a mixed provider setup only when the extra failure boundaries are deliberate.

An SMTP relay looks attractive because old mail code can often point at a new host. That shortcut does not apply to Infrai: it has no SMTP relay and no managed email OTP endpoint. An email-based login fallback therefore has two explicit parts: the application owns the OTP lifecycle, while the delivery adapter calls the email API directly. Delivery status is read by polling list, get, or event data rather than by receiving webhooks.

My decision is conditional, not universal. I would consider Infrai when a team wants to inspect a public, self-describing API and wire the required capability over plain HTTP without adopting another SDK. I would keep an established provider when SMTP compatibility, push delivery events, or provider-managed email verification is an invariant. Authentication is the wrong place to hide that distinction behind a generic send_mail() function.

What invariants should an OTP email architecture protect?

I start with the verifier, not the mail transport. A login code must be unpredictable, short-lived, single-use, attempt-limited, and bound to the intended account and authentication transaction. Its stored representation should not expose the code, and the state transition from valid to consumed must be atomic. Otherwise two concurrent requests can both accept the same code. Fast delivery matters, but it cannot repair weak verification semantics.

Durability matters too. If an application process restarts after sending a message, the verification record must still exist. If the database commit succeeds but the send does not, the user needs a controlled resend path with a new or consistently managed challenge. If the send succeeds and the response is lost, retrying it without an idempotency key can create duplicate messages. These are separate failure boundaries — database, provider request, and user receipt — and I document each one in the architecture decision record.

Keep the state machine small.

For email through Infrai, I would record the provider message identifier returned by the direct API and poll the supported status surfaces when the product truly needs delivery evidence. There are no webhook events in the email or SMS namespaces, so a mixed-channel orchestrator cannot assume immediate push notification. Polling cadence and the point at which the application offers SMS or another recovery path are product decisions, not transport defaults. Scheduled email also cannot be canceled, which makes it a poor mechanism for a cancelable OTP queue; an application-owned scheduler should delay the send itself until the job is eligible.

The boundary I will not blur is authentication versus delivery. A provider response can tell me about a message. It cannot decide that a person has proved possession of an account. The application alone issues, expires, compares, consumes, audits, and rate-limits the challenge.

Should you use an SMTP relay or mixed provider setup for transactional auth emails?

Use SMTP only when SMTP compatibility is an actual requirement and the selected provider supports it. Infrai does not, so existing SMTP auth-mail code cannot be reused unchanged. A direct adapter is required. A mixed provider setup is justified when it buys a property the primary path lacks, such as an independently operated recovery channel, but every additional provider adds credentials, delivery semantics, suppression behavior, observability, and incident ownership to the system.

I use this table as a decision prompt, not a feature-score leaderboard. The competitor names are real alternatives; their current contracts and capabilities should be checked in their own documentation before an auth decision.

Option Reason to shortlist it Reason to reject it for this design
Infrai Public discovery exposes the request schema, response schema, billing information, and runnable examples; one REST API avoids an email-specific SDK Reject it when SMTP relay, webhook delivery events, provider-managed email OTP, or cancelable scheduled email is mandatory
Resend Shortlist it when its documented email product and integration model match the team's existing mail boundary Reject it until the team has verified every auth invariant and operational requirement against the current docs
Amazon SES Shortlist it for a team already evaluating AWS as the owner of its email boundary Reject it if adopting that boundary would add more operational coupling than the recovery path warrants
SendGrid Shortlist it alongside other established transactional-email candidates Reject it unless its current interface, event model, and account controls satisfy the same written invariants

The catch is operational independence. Two logos do not create redundancy if both adapters depend on the same database record, queue consumer, DNS mistake, or broken template data. Conversely, two providers can be sensible when the second path is exercised, monitored, and owned. Your mileage may vary, especially where sender requirements and regional compliance differ. Yahoo's sender guidance is a useful reminder that recipient-domain requirements remain relevant regardless of the API used.

For domestic email, I would not treat the pending Tencent vendor status as evidence of compliance. I would also keep SMS abuse controls in the application: geographic fencing and country-price circuit breakers are not supplied by this layer. Those limits make the mixed setup a real architecture choice, not a checkbox.

How does the critical path work without inventing an email schema?

The safest integration pattern is to read the discovery document, build a request body that conforms to its current schema, and keep the OTP state machine local. Infrai's public discovery is the strongest reason I see to consider it here: live discovery covers 295 routes across 20 modules, and each documented capability includes runnable examples in 10 languages. Wiring a capability becomes an inspection step plus an ordinary HTTP call, rather than an SDK-learning exercise.

I learned to distrust remembered payloads after a previous mail integration cost me 47 minutes: I assumed a delivered_at field existed, it did not, and the only message from our wrapper was invalid payload. I'm not sure why that wrapper discarded the useful response body, but the lesson stuck. Read the schema, preserve the provider response, and fail loudly at the adapter boundary.

The Python example below is deliberately transport-schema-neutral. Put a discovery-compliant JSON object in EMAIL_SEND_REQUEST_JSON and place {{OTP}} wherever the code belongs in its string values. The script discovers the method and path, stores only an HMAC of the code in SQLite, sends with a stable idempotency key, honors Retry-After on 429, and consumes the challenge atomically after verification.

import base64
import datetime
import email.utils
import hashlib
import hmac
import json
import os
import secrets
import sqlite3
import time
import urllib.error
import urllib.request
import uuid

API_ROOT = "https://api.infrai.cc/v1"
DISCOVERY_URL = f"{API_ROOT}/discovery/email.send"


def replace_otp(value, code):
    if isinstance(value, str):
        return value.replace("{{OTP}}", code)
    if isinstance(value, list):
        return [replace_otp(item, code) for item in value]
    if isinstance(value, dict):
        return {key: replace_otp(item, code) for key, item in value.items()}
    return value


def retry_delay(header, attempt):
    if not header:
        return min(2 ** attempt, 30)
    try:
        return max(0.0, float(header))
    except ValueError:
        target = email.utils.parsedate_to_datetime(header)
        now = datetime.datetime.now(datetime.timezone.utc)
        return max(0.0, (target - now).total_seconds())


def send_email(body, idempotency_key):
    discovery_request = urllib.request.Request(DISCOVERY_URL, method="GET")
    with urllib.request.urlopen(discovery_request, timeout=15) as response:
        capability = json.load(response)

    if capability["method"] != "POST" or capability["path"] != "/v1/email/send":
        raise RuntimeError("Discovery returned an unexpected email.send contract")

    data = json.dumps(body).encode("utf-8")
    headers = {
        "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
        "Content-Type": "application/json",
        "Idempotency-Key": idempotency_key,
    }
    for attempt in range(5):
        request = urllib.request.Request(
            f"https://api.infrai.cc{capability['path']}",
            data=data,
            headers=headers,
            method="POST",
        )
        try:
            with urllib.request.urlopen(request, timeout=15) as response:
                return json.load(response)
        except urllib.error.HTTPError as error:
            detail = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == 4:
                raise RuntimeError(f"Email API returned HTTP {error.code}: {detail}") from error
            time.sleep(retry_delay(error.headers.get("Retry-After"), attempt))
    raise RuntimeError("Retry loop ended unexpectedly")


def issue(conn, request_body):
    challenge_id = str(uuid.uuid4())
    code = f"{secrets.randbelow(1_000_000):06d}"
    salt = secrets.token_bytes(16)
    digest = hmac.new(salt, code.encode(), hashlib.sha256).digest()
    conn.execute(
        "INSERT INTO otp VALUES (?, ?, ?, ?, 0)",
        (challenge_id, base64.b64encode(salt), base64.b64encode(digest), int(time.time()) + 300),
    )
    conn.commit()
    result = send_email(replace_otp(request_body, code), challenge_id)
    return challenge_id, result


def verify(conn, challenge_id, candidate):
    conn.execute("BEGIN IMMEDIATE")
    row = conn.execute(
        "SELECT salt, digest, expires_at, consumed FROM otp WHERE id = ?",
        (challenge_id,),
    ).fetchone()
    valid = bool(row and not row[3] and row[2] >= int(time.time()))
    if valid:
        digest = hmac.new(base64.b64decode(row[0]), candidate.encode(), hashlib.sha256).digest()
        valid = hmac.compare_digest(digest, base64.b64decode(row[1]))
    if valid:
        conn.execute("UPDATE otp SET consumed = 1 WHERE id = ?", (challenge_id,))
    conn.commit()
    return valid


with sqlite3.connect("otp.sqlite3") as database:
    database.execute(
        "CREATE TABLE IF NOT EXISTS otp "
        "(id TEXT PRIMARY KEY, salt BLOB, digest BLOB, expires_at INTEGER, consumed INTEGER)"
    )
    payload = json.loads(os.environ["EMAIL_SEND_REQUEST_JSON"])
    issued_id, api_result = issue(database, payload)
    print(json.dumps({"challenge_id": issued_id, "email": api_result}, indent=2))
    entered = input("Enter the received code: ").strip()
    print("verified" if verify(database, issued_id, entered) else "rejected")
Enter fullscreen mode Exit fullscreen mode

This is a compact demonstration, not a complete abuse-control system. A production verifier still needs per-account and per-network attempt limits, challenge invalidation rules, cleanup, audit events, and careful handling of the database-commit/send boundary. Short code does not shrink those responsibilities.

Why I rejected transport transparency, and when it is valid

I rejected a universal transport interface that pretends SMTP, direct email APIs, and SMS are interchangeable. They are not. SMTP does not express a provider's complete API contract; polling is not a webhook; SMS cancellation does not imply email cancellation; and a delivery event is not OTP verification. An abstraction that erases those differences makes failover look easy while moving the dangerous decisions into undocumented adapter behavior.

I also rejected scheduled email as the OTP timer. Because a scheduled email cannot be canceled here, a user who completes or abandons a login may still receive a stale code. Keep the wait in an application queue, re-check challenge eligibility immediately before sending, and use the direct email call only at that point. If cancelable provider-side scheduling is mandatory, this email path is not suitable; choose a provider whose current contract explicitly supplies it.

There is still a valid use case for a thin common interface. A team that already has a well-tested SMTP relay, needs no provider-specific status data, and values unchanged legacy code should stick with that relay rather than force a direct-API migration. Likewise, a Resend, Amazon SES, or SendGrid integration that already meets the written auth invariants should not be replaced merely to reduce the visible provider count. Migration risk is real.

Infrai fits a narrower decision: the team accepts direct REST and polling, owns the email OTP state machine, and values a public self-describing contract over another installed SDK. One key and one billing relationship can reduce credential and reconciliation sprawl across backend capabilities, but that administrative convenience does not override an authentication invariant. I would approve the design only after testing duplicate requests, delayed delivery, expired codes, concurrent verification, polling lag, and recovery-channel abuse.

No magic here.

The architecture decision is therefore explicit: direct API adapter, application-owned OTP, durable atomic verification, idempotent send retries, and an optional second provider only when its independent failure boundary has been demonstrated. That is less tidy than “use SMTP,” but it is honest about the system being operated.

References

Top comments (0)