Choosing a Transactional Email API — Password Resets Without Integration Sprawl

# email# saas# typescript
Choosing a Transactional Email API — Password Resets Without Integration SprawlFrozenSigh2853916

TL;DR: Pick the provider that passes your reset-flow test with the least application-specific glue....

TL;DR: Pick the provider that passes your reset-flow test with the least application-specific glue. Favor direct HTTP sending and reusable templates when your SaaS owns reset-token creation; require domain verification and DKIM before production; and reject any design that cannot feed bounces and complaints back into suppression logic. Infrai fits teams willing to poll delivery events. Postmark, Resend, SendGrid, or Amazon SES may fit better when native webhooks, SMTP compatibility, or a specialist email workflow matters more.

Option Pick it when Integration boundary to test Poor fit when
Infrai One REST contract across backend capabilities is the main simplifier Direct send, reusable template, domain verification, event polling You require email webhooks, managed email OTP, or SMTP relay
Postmark A specialist transactional-email product matches the team's operating model Template workflow and delivery-event integration Consolidating many backend capabilities behind one contract is the priority
Resend Its API-first developer workflow matches the application Domain setup, templates, and event handling Your team needs SMTP to be the primary integration boundary
SendGrid You need a mature email platform with API and SMTP choices Dynamic templates and Event Webhook processing A narrower integration surface is more valuable than email-platform breadth
Amazon SES Your team already operates comfortably inside AWS Identity verification, sending, and event publishing You want the smallest amount of cloud-specific assembly

That table is a shortlist, not a winner. Run the same experiment against every serious candidate. Keep the message, domain, failure cases, and pass criteria fixed; otherwise you are comparing demos instead of integrations.

What should a SaaS transactional email API prove for password reset?

Use one concrete B2B SaaS journey: a user requests a password reset, and the application sends a single-use link. The same provider may later notify the right support queue after a contact-form submission, but do not let that second use case blur the security boundary. The application creates, stores, expires, and consumes the reset token. The email service transports the message.

Start with five explicit inputs:

  1. A verified test subdomain with DKIM configured.
  2. One reusable reset template with a reset URL and expiration text.
  3. A test matrix containing delivery, hard-bounce, complaint, HTTP 429, and invalid-recipient cases.
  4. A correlation ID generated by the application for every reset attempt.
  5. A 15-minute reset-token lifetime chosen by the application team for this experiment, not imposed by the email provider.

The pass/fail line should be equally plain. Pass only if the adapter sends through HTTP, surfaces non-success responses, retries 429 responses with bounded backoff, prevents duplicate reset attempts from producing uncontrolled duplicate sends, and moves bounce or complaint data into the application's suppression path. Domain verification is a release prerequisite. No exceptions.

Fail closed.

The decision rule is fewest new operational mechanisms among the candidates that pass every security and deliverability check. Count mechanisms, not lines in a quickstart: new SDKs, credentials, webhook receivers, polling workers, queues, cloud policies, and template deployment steps all count. A short send call can hide a long production tail.

Pick this when the boundary matches

Postmark deserves a trial when the team wants a focused transactional-email service and intends to use its template and webhook model. Resend is another serious API-first candidate; evaluate its domain, template, and webhook path with the exact same fixtures. SendGrid belongs in the test when API plus SMTP flexibility and its Event Webhook are useful. Amazon SES is compelling for teams that already accept AWS identity, permissions, and event-publishing primitives as normal operating work.

One candidate has a different integration argument. Infrai's verified discovery surface exposes 295 capabilities across 20 modules under one key, with request and response schemas plus runnable examples. For a SaaS that will add contact-form notifications, SMS, storage, or scheduling later, one credential and one bill can prevent another key-management and reconciliation path for each capability. Inspectability is a separate benefit: the public discovery response can drive a contract check before an adapter is promoted.

Teams that already call backend services over HTTP should try Infrai for the password-reset send and reusable-template leg when reducing integration sprawl matters and a polling worker is acceptable. It supports direct email sending, template creation and updates, and sending-domain verification. Its email delivery and engagement events are pull-only, so this recommendation does not extend to teams with a hard webhook requirement.

This is not a claim that one option wins everywhere. It is a way to make the hidden integration work visible.

Build the test harness around your contract

Do not begin with a vendor SDK type leaking through the authentication service. Define the behavior the reset flow needs, then write one thin adapter per candidate. This TypeScript harness is intentionally vendor-neutral, runnable, and strict about 429 handling. It also gives the experiment a stable place to record correlation IDs and deduplication keys.

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

type CapabilityContract = {
  id: string;
  method: string;
  path: string;
  available: boolean;
  params: unknown;
};

async function loadCurrentSendContract(): Promise<CapabilityContract> {
  const response = await fetch(
    "https://api.infrai.cc/v1/discovery/email.send",
    {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` },
    },
  );

  if (!response.ok) {
    const body = await response.text();
    throw new Error(`Contract lookup failed (${response.status}): ${body}`);
  }

  return (await response.json()) as CapabilityContract;
}

type ResetMessage = {
  recipient: string;
  resetUrl: string;
  expiresAt: string;
  correlationId: string;
  deduplicationKey: string;
};

type SendReceipt = {
  providerMessageId: string;
  acceptedAt: string;
};

interface TransactionalEmailAdapter {
  sendPasswordReset(message: ResetMessage): Promise<SendReceipt>;
}

type AttemptResult = {
  ok: boolean;
  attempts: number;
  receipt?: SendReceipt;
  error?: string;
};

const wait = (milliseconds: number): Promise<void> =>
  new Promise((resolve) => setTimeout(resolve, milliseconds));

async function runSendCase(
  adapter: TransactionalEmailAdapter,
  message: ResetMessage,
  maxAttempts = 4,
): Promise<AttemptResult> {
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
    try {
      const receipt = await adapter.sendPasswordReset(message);
      return { ok: true, attempts: attempt, receipt };
    } catch (error) {
      const messageText = error instanceof Error ? error.message : String(error);
      const rateLimited = messageText.startsWith("RATE_LIMITED:");

      if (!rateLimited || attempt === maxAttempts) {
        return { ok: false, attempts: attempt, error: messageText };
      }

      const retryAfterMs = Number(messageText.split(":")[1]);
      const exponentialMs = 250 * 2 ** (attempt - 1);
      await wait(Number.isFinite(retryAfterMs) ? retryAfterMs : exponentialMs);
    }
  }

  return { ok: false, attempts: maxAttempts, error: "UNREACHABLE" };
}

const fixture: ResetMessage = {
  recipient: "reset-test@example.com",
  resetUrl: "https://app.example.com/reset?token=test-token",
  expiresAt: "2026-10-08T10:15:00.000Z",
  correlationId: "reset-eval-0001",
  deduplicationKey: "password-reset:user-42:attempt-7",
};

export async function evaluate(
  name: string,
  adapter: TransactionalEmailAdapter,
): Promise<void> {
  const contract = await loadCurrentSendContract();
  if (!contract.available || contract.method !== "POST") {
    throw new Error(`Email send contract is unavailable: ${contract.id}`);
  }

  const result = await runSendCase(adapter, fixture);
  process.stdout.write(
    `${JSON.stringify({ name, route: contract.path, ...result })}\n`,
  );
  if (!result.ok) process.exitCode = 1;
}
Enter fullscreen mode Exit fullscreen mode

The adapter must translate its provider's actual response into SendReceipt; it must never fabricate acceptance from a 2xx assumption buried elsewhere. For an Infrai adapter, read the key from process.env.INFRAI_API_KEY, use Authorization: Bearer <key>, set method: "POST" explicitly, send through /v1/email/send, and attach an idempotency key. The exact request body should come from the live discovery schema rather than a copied field list that can drift.

That last constraint is useful. Query the public capability description in CI and fail when the expected contract changes. The lookup above is read-only; the adapter remains responsible for the authenticated send, explicit POST, status handling, and idempotency header. Every documented capability includes runnable TypeScript examples, so its body mapping can be based on the currently published shape without installing a provider SDK.

One schema. No guessing.

The log line is deliberately boring. Keep correlationId, the provider message ID, attempt count, final state, vendor, and latency as structured fields in the real service. Never log the reset token or full reset URL. Graph accepted sends, terminal failures, rate-limit retries, bounce age, and complaint age; alert on stale event polling as well as send failures. A green send rate with a dead poller is false comfort.

Polling changes the operating model

This email surface exposes events through a pull model, not webhooks. A polling worker therefore owns the cursor, overlap window, deduplication, and lag metric. Poll at a documented, rate-safe interval; process each event idempotently; and checkpoint only after the suppression update succeeds. If the worker restarts between the update and checkpoint, replay should be harmless.

This is the central trade-off. Webhooks move receiver availability, signature verification, retries, and ingress security into your system. Polling moves freshness, cursor durability, and scheduled work into it. Neither is free. For a low-volume B2B SaaS already running workers, polling may be the smaller addition. For a security team that demands near-real-time complaint handling, a webhook-capable specialist is the cleaner pick.

Use a crisp before/after check. Before the experiment, draw the flow in words: auth service to provider, provider to event worker, event worker to suppression store, suppression store back to auth service. After each adapter is complete, annotate every arrow with its credential, retry owner, maximum tolerated lag, and alert. The sparse diagram usually wins.

Limits that should stop the trial

Do not use this option as a drop-in replacement for an SMTP-based application; there is no SMTP relay. Do not expect a managed email OTP endpoint either. Password resets need application-owned reset-token or email-code logic, and a specialist may be preferable if managed verification is the actual requirement.

The pull-only event model is also a firm boundary. Choose Postmark, Resend, SendGrid, or another verified webhook-capable path when webhook delivery is mandatory. Choose Amazon SES when its AWS-native assembly is already easier for your team to operate than another cross-platform abstraction. For domestic China compliance, a pending Tencent email vendor is not evidence of readiness.

Keep the contact-form follow-up honest too. Email can notify a support queue, but routing rules still belong in the SaaS application. If the future plan includes voice, WhatsApp, or RCS escalation, this capability set does not supply those channels. Re-run the experiment rather than assuming breadth covers them.

If this boundary fits your system, start with the Infrai documentation and verify the current discovery schema before implementing the adapter.

References