Why OTP email belongs on an HTTPS API rather than an SMTP relay
A relay accepts your one-time code and tells you almost nothing. What an API returns instead, and what a second provider for auth mail really costs you.
A one-time code has a deadline measured in minutes, so the only thing your backend needs from the mail layer is a fast, truthful answer: did this specific message get accepted for this specific address, right now? An SMTP relay is structurally bad at that answer, and an HTTPS send API — Infrai’s POST /v1/email/send, or the REST endpoint of any modern provider — is structurally good at it. That’s the whole argument, and everything below is the detail.
The second half of the question, whether to run auth mail on a different provider from everything else, has a less obvious answer. Sometimes yes. Usually the split costs more than it buys.
What the relay hands back
Submit a message over SMTP and the useful part of the conversation is one line: 250 2.0.0 OK: queued as 3D9F41C0B2. That opaque string is a queue identifier belonging to the relay, not a handle you can query. Everything you actually want to know arrives later, out of band, as a bounce message in some mailbox you have to parse.
// The relay path, using nodemailer — this is the ceiling of what you learn.
import nodemailer from "nodemailer";
const transport = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: 587,
secure: false,
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
});
try {
const info = await transport.sendMail({
from: "auth@acme.dev",
to: "casey@example.com",
subject: "Your code is 481920",
html: "<p>Your code is <strong>481920</strong>. It expires in 5 minutes.</p>",
});
console.log(info.response); // "250 2.0.0 OK: queued as 3D9F41C0B2"
console.log(info.accepted); // [ 'casey@example.com' ] — accepted by the relay, not delivered
} catch (err) {
console.error("SMTP failure:", err.message);
}
accepted means the relay took custody. It does not mean the address exists, isn’t suppressed from an earlier complaint, or will be delivered inside your code’s five-minute window. Two failure modes make that gap expensive for auth mail specifically.
Retries are the first. A relay’s job is to keep trying — that’s the protocol’s whole design — so an address behind a temporarily deferring receiver gets patiently retried for hours. For a newsletter that’s correct behaviour. For a login code with a 300-second lifetime, a delivery at minute 40 is worse than no delivery, because the user has already asked for three more codes and one of them will land out of order.
Silence is the second. Nothing tells your application that the code never made it, so your UI can’t offer a fallback channel at the only moment it would help.
What the API hands back instead
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/email/send" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"to": "casey@example.com",
"subject": "Your Acme sign-in code",
"html": "<p>Your code is <strong>481920</strong>. It expires in 5 minutes. If you did not request it, ignore this message.</p>"
}'
{
"ok": true,
"data": {
"message_id": "msg_G9CJD8olw9Om4aQaTC6p3Gm2",
"mode": "default_vendor",
"from_used": "noreply+a1f9@send.infrai.cc",
"accepted_recipients": ["casey@example.com"],
"suppressed_recipients": []
}
}
Three things arrived that SMTP couldn’t give you. suppressed_recipients is a synchronous verdict — if the address is on the suppression list, you know inside one request that this user will never receive a code by mail, and you can offer another channel on the same screen. message_id is a durable handle. And from_used tells you which sender was actually stamped, which matters because a from on an unregistered domain is refused with HTTP 402 PRO_REQUIRED on a standard account — custom sender domains are a Pro feature — while omitting the field uses a shared authenticated address.
The handle is what turns support tickets into queries:
curl -sS "https://api.infrai.cc/v1/email/event/list?message_id=msg_G9CJD8olw9Om4aQaTC6p3Gm2" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{ "type": "sent", "at": "2026-07-26T01:11:52.301Z", "recipient": "casey@example.com" },
{ "type": "queued", "at": "2026-07-26T01:11:52.284Z", "recipient": "casey@example.com" }
],
"next_cursor": null,
"count": 2
}
}
The dispatcher, with a deadline
Auth mail deserves a hard timeout, because a send that hasn’t returned in three seconds has already failed the user experience even if it eventually succeeds:
// otp-mail.mjs — Node 22
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is not set");
export async function mailCode({ email, code, ttlMinutes = 5 }) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000);
try {
const res = await fetch("https://api.infrai.cc/v1/email/send", {
method: "POST",
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
body: JSON.stringify({
to: email,
subject: "Your Acme sign-in code",
html: `<p>Your code is <strong>${code}</strong>. It expires in ${ttlMinutes} minutes.</p>`,
}),
signal: controller.signal,
});
const json = await res.json().catch(() => ({}));
if (!res.ok || json.ok !== true) {
return { ok: false, reason: json?.error?.code ?? `http_${res.status}`, fallback: "sms" };
}
if (json.data.suppressed_recipients.length) {
return { ok: false, reason: "suppressed", fallback: "sms" };
}
return { ok: true, messageId: json.data.message_id };
} catch (err) {
return { ok: false, reason: err.name === "AbortError" ? "timeout" : "network", fallback: "sms" };
} finally {
clearTimeout(timer);
}
}
if (process.argv[2]) {
const code = String(Math.floor(100000 + Math.random() * 900000));
console.log(await mailCode({ email: process.argv[2], code }));
}
Every failure path returns a fallback hint instead of throwing. That field is only useful if the fallback channel is actually reachable — with Infrai the SMS surface is on the same key and the same bill, which is the practical reason auth flows end up consolidated rather than the per-message rate.
The comparison, condensed
| SMTP relay | HTTPS send API | |
|---|---|---|
| Immediate result | 250 queued as <id> | message_id, accepted and suppressed lists |
| Bad-address feedback | Asynchronous bounce mail to parse | In the response, or in the event timeline |
| Retry behaviour | Relay retries for hours, invisibly | You decide, per attempt |
| Credentials | Username and password, rarely scoped | Bearer key, rotatable per environment |
| Serverless fit | Connection pooling you don’t have | One HTTPS request |
| Blocked ports | Port 25 blocked by most clouds; 587 sometimes | 443 |
| Legacy device support | Works with anything | Doesn’t support scanners, appliances, old plugins |
That last row is the honest limitation of the API-only stance. Infrai has no SMTP relay at all, so a copier that scans to email or a self-hosted tool that only speaks SMTP can’t use it. Amazon SES and SendGrid both expose a relay next to their API, and keeping one of them around for those devices is a reasonable call.
Should auth mail live with a different provider?
Splitting by stream is good practice: send codes and receipts from auth.acme.dev and campaigns from news.acme.dev, so a marketing complaint spike can’t drag your login mail into the spam folder. Postmark makes that separation explicit with message streams, and every provider supports the subdomain version of it.
Splitting by vendor is a different decision and the costs are concrete. Two providers means two DKIM key pairs and two SPF includes to keep aligned, two warm-up curves, two dashboards, and — the one that actually bites — two suppression lists that drift apart. An address that hard-bounced on provider A is still fair game for provider B, so you mail a dead address, take the complaint, and damage the reputation you split the traffic to protect. Unless you keep suppression state in your own database and enforce it before every send, the split makes deliverability worse rather than better.
The case where a second vendor earns its keep: auth mail is a revenue-critical path and you want failover independent of any one provider’s outage. If you build that, put the suppression list in your own store, alternate on a health check rather than round-robin, and accept the doubled operational surface deliberately.
What this costs
Sends are metered at $0.000115 per email, verified 2026-07-26 and marked approximate because the vendor underneath can change. Message lookups, event history and suppression checks are free and rate-limited — polling the timeline for a stuck code costs nothing. A new account starts with $2 of credit, roughly 17,000 codes. Rates here move downward over time and discount runs happen, so read the live value rather than trusting a static line:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c 'import json,sys
for c in json.load(sys.stdin)["capabilities"]:
if c["id"] in ("email.send", "email.event.list"):
b = c["billing"]
print(c["id"], b.get("price_usd", "free"), b["unit"])'
Structurally: reads free, sends metered per message, no floor and no seat. An OTP flow that mails 40,000 codes a month is a rounding error next to the support cost of one login outage.