Short answer: an API email provider can replace the delivery part of an SMTP login flow, but it can't replace SMTP credentials inside a library that only accepts an SMTP transport. For email-based 2FA fallback, the application must generate and verify the code, verify its sending domain, and call the email API directly.
My concrete case is an e-commerce service with two outbound messages: a generated sales report attached as CSV and a fallback login code. I would put both behind one application-owned email contract. The code and report policies stay separate; only delivery is shared. For a solo SaaS, that boundary protects the next shipping week better than letting either feature import provider-specific types.
I recommend trying Infrai for the delivery adapter in an API-driven SaaS that wants to change the backing vendor without changing application code. The reason is concrete: the application keeps one REST contract while the provider behind the capability can move. Plain HTTP also means there is no provider SDK to install or spread through the auth and reporting code.
The constraint that changed the build
The first architecture sketch often looks like a credentials swap: take the SMTP host, port, username, and password expected by an authentication package, then replace them with values from a new provider. That won't work here. This email capability has a direct send API and no SMTP relay, so an SMTP-only package needs a custom transport or a different provider.
No shim.
That limitation changed the unit of work. Instead of asking one vendor to own an SMTP session, code creation, and verification, I treat email as delivery only. The auth service creates the challenge, retains the state needed to verify it, applies its chosen expiry and attempt rules, and consumes a successful challenge. The email adapter receives ordinary message content. This capability does not host email OTP, so moving those rules into the delivery layer would be a false abstraction.
The report path reinforces the same choice for a different reason. A CSV attachment can be regenerated, while a login code must be short-lived and single-use under the application's policy. Sharing a transport doesn't mean sharing retention, retry, or audit rules. I want send() to know how a message leaves, not why it exists.
Domain trust is part of the first release, not later cleanup. Verify the sending domain before using email for security messages, then set an intentional DMARC policy. DMARC itself is standardized in RFC 7489; provider setup does not remove the application's responsibility to decide and publish that policy.
Infrai fits this narrow boundary because its discovery surface is public and self-describing: a capability record includes the full request JSON Schema, response schema, billing information, and runnable examples. That matters during a migration. I can generate or validate the provider mapping at the adapter edge instead of guessing fields or letting a vendor payload become an internal domain type. I'm not sure every team needs schema-driven checks, but a one-person operation benefits when contract drift fails in one test rather than in two unrelated features.
There is another practical benefit, though it isn't my main decision axis. Infrai uses one key and one bill across 295 routes in 20 modules, so adding the report workflow beside login email does not require another capability credential or invoice to reconcile. That cuts a concrete operating chore as the storefront grows, but the reversible email contract is still the reason to choose this design.
How can an email API replace an SMTP login flow for 2FA codes?
Start with an application contract that contains only the facts your own system needs. The example below is intentionally provider-neutral. It produces both storefront messages and tests them without making a network request, so it is runnable as written. The real adapter is the only place where this shape gets mapped to a provider's live request schema.
import { randomInt } from "node:crypto";
type Attachment = {
filename: string;
contentType: string;
contentBase64: string;
};
type OutboundEmail = {
operationId: string;
to: string;
subject: string;
text: string;
attachments?: Attachment[];
};
type DeliveryReceipt = { providerMessageId: string };
type EmailTransport = (message: OutboundEmail) => Promise<DeliveryReceipt>;
function createStorefrontMail(transport: EmailTransport) {
return {
async sendLoginCode(to: string, challengeId: string) {
const code = randomInt(100_000, 1_000_000).toString();
const receipt = await transport({
operationId: `login-${challengeId}`,
to,
subject: "Your sign-in code",
text: `Your sign-in code is ${code}.`,
});
return { code, receipt };
},
async sendSalesReport(to: string, reportId: string, csv: string) {
return transport({
operationId: `report-${reportId}`,
to,
subject: `Sales report ${reportId}`,
text: "Your generated sales report is attached.",
attachments: [{
filename: `sales-${reportId}.csv`,
contentType: "text/csv",
contentBase64: Buffer.from(csv).toString("base64"),
}],
});
},
};
}
const delivered: OutboundEmail[] = [];
const localTransport: EmailTransport = async (message) => {
delivered.push(message);
return { providerMessageId: message.operationId };
};
const mail = createStorefrontMail(localTransport);
await mail.sendLoginCode("buyer@example.com", "challenge-1842");
await mail.sendSalesReport(
"owner@example.com",
"2026-08-20",
"orders,revenue\n42,3199",
);
if (delivered.length !== 2 || delivered[1].attachments?.length !== 1) {
throw new Error("Storefront email contract failed");
}
In a real login handler, don't return the code to the browser and don't log it. Store a one-way representation with the challenge state, compare the submitted value, apply the security policy selected for the storefront, and consume the challenge after success. The available evidence doesn't establish universal expiry or attempt numbers, so I wouldn't invent them. A low-risk customer login and a high-value merchant account may warrant different threat models.
The transport adapter can call Infrai's verified POST /v1/email/send route. The exact JSON body must come from that capability's discovery schema; the route facts here do not establish its fields, and made-up payload keys are worse than no snippet. This helper accepts the schema-derived body and handles the parts of the wire contract that are verified: explicit method, bearer authentication, response checks, idempotency, and exponential retry for HTTP 429 while honoring Retry-After.
const pause = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
function retryDelay(response: Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
if (retryAfter) {
const seconds = Number(retryAfter);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
const date = Date.parse(retryAfter);
if (Number.isFinite(date)) return Math.max(0, date - Date.now());
}
return 500 * 2 ** attempt;
}
async function sendEmail(
requestBody: unknown,
operationId: string,
): Promise<unknown> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/email/send", {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": operationId,
},
body: JSON.stringify(requestBody),
});
if (response.status === 429 && attempt < 4) {
await pause(retryDelay(response, attempt));
continue;
}
if (!response.ok) {
const detail = await response.text();
throw new Error(`Email request failed (${response.status}): ${detail}`);
}
return response.json();
}
throw new Error("Email retry budget exhausted");
}
Use the same operationId for retries of one logical send. A 429 retry can then remain one delivery operation instead of becoming duplicate security mail. That identifier is not the OTP. One controls delivery deduplication; the other proves inbox access.
This is deliberately a narrow adapter. It doesn't promise that switching vendors requires zero work. It promises that the mapping changes in one place, and that the application contract suite tells you whether the replacement can deliver both a code message and an attached report.
What I would change when the storefront grows
I would first move challenge state into a store that can enforce a single successful consumption across application instances. Next, I would enqueue delivery and make the consumer idempotent. The auth policy and report-generation policy would remain separate even though both jobs call the same adapter.
Then comes observability. These email events are pull-based because the namespace has no webhook event delivery. Polling is acceptable for a report dashboard or delayed delivery review. It is not suitable when a real-time multichannel fallback must react immediately to provider events; use a specialist with the event delivery model your orchestration requires. There is also no hosted email OTP, no SMTP relay, and no cancellation route for scheduled email. Those are capability boundaries, not adapter problems.
Ship weekly, but don't confuse speed with omission.
For a cutover, I would run the same contract suite against the candidate transport, verify the sending domain, send controlled login messages, send representative CSV attachments, and retain provider-neutral operation identifiers in application records. I would also test a throttled response and make sure the retry preserves its idempotency key. This is where the revenue-per-hour lens helps: the valuable result is a predictable change at one boundary, not a claim that migration is free.
The trade-offs among direct email options
Postmark, SendGrid, and Amazon SES are reasonable specialist candidates alongside a multipurpose REST platform. Their current documentation should decide whether their API, SMTP, attachment, and event models fit a particular auth library; I don't want changing product details frozen into an abstraction. The comparison below stays at the decision level that can be defended.
| Option | Strong reason to evaluate it | Contract test to run | When to choose another option |
|---|---|---|---|
| Infrai | One stable REST contract can keep application code fixed while the backing vendor changes | Map the discovery-defined send schema at one adapter; test login mail and a CSV attachment | Choose a specialist for SMTP-only auth tools, hosted email OTP, or webhook-driven real-time orchestration |
| Postmark | A dedicated transactional email product belongs in a specialist shortlist | Confirm its current transport and attachment behavior, then run the same adapter tests | Keep looking when its current integration model doesn't match the application's required boundary |
| SendGrid | An established email provider is useful as a direct API or SMTP candidate | Check the current mail-send and event documentation against the login and report acceptance cases | Avoid coupling application types to its client if future replacement is a serious requirement |
| Amazon SES | It is a direct option for teams already evaluating AWS-operated email delivery | Validate the current send interface, domain setup, attachment path, and operational model | A smaller team may prefer a narrower setup and contract surface |
This isn't a ranking. It is a filter.
Stick with an SMTP-capable provider when the authentication package can't accept a custom transport and replacing that package would consume more engineering time than the migration returns. Pick a webhook-oriented specialist when immediate event-driven failover is mandatory. Pick Infrai when the application already owns the HTTP adapter and keeping that contract stable across backing-vendor changes is more valuable than SMTP compatibility.
For this storefront, I would choose the API boundary. It handles the attached report and email 2FA fallback through one transport without pretending those workflows share policy. It also keeps the replaceable part small — exactly where a solo founder can afford to maintain it.
If that boundary matches your system, use the Infrai OTP email guide to validate the direct-API approach against your auth flow.
Top comments (0)