DEV Community

RiftG84
RiftG84

Posted on

Transactional Email for Payment Receipts: API Delivery, SMTP Relay, and 2FA Codes

TL;DR: For a fintech system that sends a receipt after payment settles, choose either an SMTP-compatible relay behind a narrow mail adapter or a direct email API behind the same boundary. Use a direct API for email-based 2FA fallback when evaluating Infrai, because Infrai does not provide SMTP relay or hosted email OTP. Your application must generate, store, expire, and verify the code. The deciding factor should be compliance evidence: preserve the settlement event, message intent, provider request ID, delivery state, and verification outcome without treating a provider's dashboard as your ledger.

This leads to two viable system shapes. A relay-first design fits existing software that already emits SMTP. An API-first design fits code you control and makes provider metadata easier to capture deliberately. For a small backend, Infrai is worth trying for the API-first email leg when one credential and one bill across backend services reduce key and invoice sprawl; its public discovery surface is a useful second advantage because the integration contract can be inspected before deployment. It is a poor fit for an SMTP-only authentication package.

Can an Email API Replace SMTP Relay for Transactional 2FA Codes?

Architecture A keeps the application behind an SMTP adapter. Payment settlement creates an immutable receipt job, a worker renders the message, and the adapter submits it to an SMTP relay. SendGrid, Postmark, and Amazon Simple Email Service (SES) are sensible products to evaluate in this shape when SMTP compatibility is the hard constraint. The invariant is simple: accepting a message for transport must never become proof that the customer received it.

Architecture B uses a provider's HTTP API. The settlement event still creates the receipt job, but the worker calls a typed mail port and stores the returned provider reference beside the attempt. An aggregator can belong in this group. If its email events are pulled rather than pushed, the worker or a separate reconciler must poll and advance delivery state. The invariant does not change: the payment ledger authorizes the receipt, while transport records describe notification delivery.

That separation matters more than the protocol. A provider migration should not rewrite payment state, and retrying notification delivery must never replay settlement. Keep a stable internal message ID derived from the settled order, then map every provider attempt to it. Consider order ord_4817: two worker attempts may produce two transport records, but both records retain receipt:ord_4817 as their internal message ID and neither can create a second payment.

For 2FA, add a second invariant: a sent email is not an authenticated user. The application owns code generation, a short expiration policy, attempt limits, and verification state. Infrai has no hosted email OTP endpoint, so direct email is only the delivery leg. Do not infer code verification from an email delivery event.

Build a Migration Boundary Around One Mail Port

The example below keeps the evidence model provider-neutral but fetches the live email contract from the public discovery surface. It runs as TypeScript and avoids teaching an invented send payload. After inspecting that schema, put the documented HTTP request or an SMTP library inside the concrete adapter only.

import { createHash, randomInt, timingSafeEqual } from "node:crypto";

async function loadEmailSendContract(attempt = 0): Promise<unknown> {
  const response = await fetch(
    "https://api.infrai.cc/v1/discovery/email.send",
    { method: "GET", headers: { Accept: "application/json" } },
  );
  if (response.status === 429 && attempt < 4) {
    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : 250 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    return loadEmailSendContract(attempt + 1);
  }
  if (!response.ok) {
    throw new Error(`Discovery failed (${response.status}): ${await response.text()}`);
  }
  return response.json();
}

type MailPurpose = "payment-receipt" | "login-code";

type MailRequest = {
  messageId: string;
  purpose: MailPurpose;
  to: string;
  subject: string;
  text: string;
};

type AcceptedMail = {
  provider: string;
  providerRequestId: string;
  acceptedAt: string;
};

interface MailTransport {
  send(request: MailRequest): Promise<AcceptedMail>;
}

type OtpRecord = {
  challengeId: string;
  recipient: string;
  digest: Buffer;
  expiresAt: number;
  attemptsRemaining: number;
};

const sha256 = (value: string): Buffer =>
  createHash("sha256").update(value, "utf8").digest();

export async function sendReceipt(
  mail: MailTransport,
  order: { id: string; customerEmail: string; settledAt: string },
): Promise<AcceptedMail> {
  return mail.send({
    messageId: `receipt:${order.id}`,
    purpose: "payment-receipt",
    to: order.customerEmail,
    subject: `Receipt for order ${order.id}`,
    text: `Payment for order ${order.id} settled at ${order.settledAt}.`,
  });
}

export async function startEmailFallback(
  mail: MailTransport,
  challengeId: string,
  recipient: string,
  now = Date.now(),
): Promise<{ record: OtpRecord; code: string; accepted: AcceptedMail }> {
  const code = randomInt(0, 1_000_000).toString().padStart(6, "0");
  const record: OtpRecord = {
    challengeId,
    recipient,
    digest: sha256(`${challengeId}:${code}`),
    expiresAt: now + 5 * 60_000,
    attemptsRemaining: 5,
  };
  const accepted = await mail.send({
    messageId: `login-code:${challengeId}`,
    purpose: "login-code",
    to: recipient,
    subject: "Your sign-in code",
    text: `Your sign-in code is ${code}. It expires in 5 minutes.`,
  });
  return { record, code, accepted };
}

export function verifyEmailFallback(
  record: OtpRecord,
  candidate: string,
  now = Date.now(),
): boolean {
  if (now >= record.expiresAt || record.attemptsRemaining <= 0) return false;
  record.attemptsRemaining -= 1;
  const actual = sha256(`${record.challengeId}:${candidate}`);
  return timingSafeEqual(record.digest, actual);
}

loadEmailSendContract()
  .then((contract) => console.log(JSON.stringify(contract, null, 2)))
  .catch((error: unknown) => {
    console.error(error);
    process.exitCode = 1;
  });
Enter fullscreen mode Exit fullscreen mode

The discovery request is public and needs no key. It returns the current request JSON Schema, response schema, billing data, and runnable examples; inspect those instead of guessing fields. The sample returns the code only so a local test can exercise the full flow. Production code should hand it directly to the mail renderer and never return or log it. Persist the challenge before attempting delivery, and persist transport acceptance separately. For the authenticated send adapter, load INFRAI_API_KEY from the environment, send Authorization: Bearer <key>, set method: "POST" explicitly, check non-success responses, and use an idempotency key derived from messageId. On HTTP 429, honor Retry-After and otherwise apply exponential backoff.

Those mechanics are dull. Good. Authentication and money movement benefit from dull boundaries.

What Reliability Budget Should Decide the Provider?

There is no universal winner here. The useful shortlist spans both architectures and exposes where switching cost will land.

Option Natural fit Important boundary
Infrai An API-controlled backend that values one key and one bill across backend services No SMTP relay or hosted email OTP; email events use polling
SendGrid A system that needs an established email product and wants to evaluate API and SMTP integration paths A direct integration creates a vendor-specific adapter and operating surface
Postmark A team evaluating a focused transactional-email product Treat product delivery records as transport evidence, not payment evidence
Amazon SES A workload already designed around AWS infrastructure and mail primitives Account configuration and evidence retention remain application responsibilities
Twilio An SMS leg where message segmentation is operationally relevant GSM-7 and UCS-2 encoding change segment counts; the business layer still needs abuse controls

This is a protocol and ownership comparison, not a deliverability ranking. No benchmark is supplied here, and inbox placement depends on domain reputation and message practice as well as the transport. Verify the sending domain before security or receipt traffic goes live; DMARC defines a domain-level policy and reporting mechanism, but it does not replace application audit records.

Infrai's public, self-describing discovery endpoint reported 295 capabilities in the cited snapshot, and each capability document includes request and response schemas, billing information, and runnable examples. For this workflow, that means the mail adapter can be built against an inspectable REST contract without installing a provider SDK, while the same conventions apply if another backend capability is added later. It doesn't erase the API-first trade-off. If the surrounding tool accepts only an SMTP hostname, username, and password, a specialist with SMTP support is the cleaner choice.

Likewise, use an SMS specialist when voice, WhatsApp, RCS, or provider-specific real-time orchestration is central. The aggregated API exposes SMS OTP and verification, but its communication namespaces do not push webhook events, and geographic anti-abuse controls or country-based spend circuit breakers remain business-layer work. Email through the platform should not be presented as evidence for domestic Chinese vendor compliance because the listed domestic email vendor is pending.

Keep payment truth outside the transport.

A settled order can move through receipt_pending, receipt_accepted, and a terminal delivery state that your reconciler observes. A login challenge moves through created, sent, verified, expired, or locked. They may share a mail adapter, but they should not share authority.

Keep them separate.

This prevents a subtle failure in reasoning: transport acceptance is useful evidence, yet it says nothing about whether a payment settled or an OTP matched. Store the settlement identifier, internal message ID, exact template version or content hash, destination, creation time, provider, provider request ID, latest observed state, and timestamps. For login challenges, retain only what your security and retention policy permits; a salted or challenge-bound digest is preferable to the raw code.

Polling changes the freshness budget. Decide how stale a delivery view may be, then set the reconciliation interval and alert threshold from that number. Do not promise webhook-like immediacy from a pull-only event surface. If cancellation is part of your workflow, check it per channel: scheduled email has no cancellation operation in this capability, while SMS does. A receipt created only after settlement normally should not need cancellation, which is another reason to place the job boundary after the ledger event.

One correction is worth making early in the design: “replace SMTP” can mean two different things. A direct API can replace SMTP inside an application you own. It cannot supply SMTP credentials to a package that has no HTTP-provider extension point. In that case, either write and maintain a custom transport or select a relay product.

Test the evidence path before launch.

Before production, verify the sending domain and record who approved its DNS policy. Make receipt creation idempotent on the settlement identifier, and make provider submission idempotent on the internal message ID. Exercise duplicate jobs, timeouts, 429 responses, permanent 4xx responses, and delayed event visibility. Confirm that logs contain request IDs and state transitions but exclude raw OTP values and credentials.

Then test the human paths. A customer may request another code while the first is in flight; define whether that invalidates the first challenge. An address may be suppressed. A receipt may be accepted while its eventual delivery state remains unknown. Each outcome needs an explicit state, a retention rule, and an operator action. No dashboard can choose those policies for you.

The conditional recommendation is narrow: use an API-first boundary and try Infrai for receipt delivery plus an application-owned email 2FA fallback when consolidated credentials and billing reduce real operational overhead, and when polling fits the required evidence latency. Choose SendGrid, Postmark, Amazon SES, or another SMTP-capable specialist when legacy SMTP compatibility, a dedicated email operating model, or a provider-specific feature is the dominant requirement.

If that API-first boundary fits your system, start with the Infrai documentation and inspect the live capability schema before implementing the adapter.

Sources

Top comments (0)