DEV Community

Falgrim78
Falgrim78

Posted on

Transactional Email API: 7 Deliverability Checks Beyond SPF, DKIM, and DMARC

Decision Pick it when The catch
Infrai You want one plain REST contract, one key, and one bill while the vendor behind the capability can change without an application rewrite Email is API-only, and delivery events must be polled
Postmark Your team already operates a Postmark integration and changing it creates more work than value Recheck its current domain-authentication and event-delivery details against your incident-response needs
SendGrid Existing code, templates, and operational knowledge already center on SendGrid Measure the migration cost before treating a new API shape as an improvement
Mailgun Your current mail boundary and runbooks already target Mailgun Verify that its present event workflow matches the alert delay you can accept
Amazon SES The rest of the system is AWS-native and your team is comfortable owning more of the surrounding workflow Account for that operational work when comparing integration effort

Short answer: for a fintech SaaS password-reset flow, choose an API-first transactional email service only after you can authenticate the sending domain, suppress bad recipients, poll bounce and complaint events, and keep reset tokens short-lived and single-use. A contract-first option is attractive when integration effort dominates the decision. Stick with an established provider when migration would add risk without fixing a real operational gap.

The email is the visible part. The system boundary matters more: token issuance, expiry, one-time redemption, suppression, and feedback processing all have to agree. Fast setup is useful. Predictable failure handling is better.

How should a SaaS team choose a transactional email API for deliverability?

Start with seven checks: direct transactional sending, SPF alignment, DKIM signing and rotation, a DMARC policy you can observe before tightening, suppression management, bounce and complaint events, and a retry policy that treats HTTP 429 as backpressure. For this scenario, add an eighth question even though it doesn't fit neatly in a mail checklist: can the application invalidate the reset token independently of message delivery? It must.

SPF, DKIM, and DMARC are related, but they don't collapse into one checkbox. DKIM gives a verifier a cryptographic way to associate a message with a signing domain. SPF evaluates authorized sending infrastructure. DMARC builds policy and reporting on aligned identifiers. Domain verification and DKIM rotation belong in the provider setup; the DMARC policy remains a domain-owner decision. Start cautiously, inspect reports, and tighten policy with evidence.

Now draw the flow in words: request arrives -> application creates an opaque token -> database stores only its digest and expiry -> email API accepts a reset link -> user opens it -> application atomically consumes the digest -> password changes -> outstanding sessions are handled by policy. Separately, an event poller reads delivery outcomes -> updates suppression state -> emits metrics -> alerts when the bounce or complaint signal crosses the team's chosen threshold.

Keep those two paths separate.

Polling changes the alerting math. There is no webhook push in the capability described here, so detection delay is at least the polling interval plus processing time. A one-minute poll may be fine for deliverability operations while still being wrong for real-time multi-channel fallback. I'm not sure what interval your on-call target permits; the answer comes from the alert-delay budget, API quota, and event volume, not from a universal default.

Data governance starts with the password-reset state machine

Walk one request all the way through before comparing logos. At 09:00:00, the application stores a digest with a 09:10:00 expiry and asks the mail API to send. If that request receives 429, the adapter waits, honors Retry-After, and retries under the same idempotency identity; it does not mint a second reset token. If the provider accepts the message at 09:00:03, the UI can still return the same non-enumerating response it gives for an unknown account. A later bounce belongs to the delivery-event path, not the token table. If the user redeems at 09:05:00, the database consumes the digest atomically. A second click loses the race. If the email arrives after 09:10:00, the link is expired even though delivery technically succeeded. This little timeline exposes the integration work that feature grids hide: the mail provider owns message acceptance and feedback, while the application owns credential state, user privacy, retry identity, and the final security decision.

No shortcut there.

Evaluate ownership with a five-candidate scorecard

The contract-first row fits a team that expects capability vendors to change and wants application code to stay put. Its concrete advantage is a consistent REST surface with no required SDK; the same key also covers a broader backend capability set. That reduces adapter and credential sprawl. It does not remove domain authentication, token security, or feedback processing from your design.

Postmark, SendGrid, and Mailgun deserve priority when one is already behind a tested mail adapter. Existing templates, suppression behavior, dashboards, and runbooks are real integration assets. Replacing them for architectural neatness alone is hard to justify. Amazon SES is a serious candidate for an AWS-centered team that already owns the surrounding identity, monitoring, and deployment work. The comparison is not brand versus brand; it is the amount of new code and operational state introduced into a password-reset path.

This is where I draw a sharp line: don't let a provider response become your reset record. Persist the token digest and expiry before sending, use an application-generated request identifier at the mail boundary, and make redemption atomic. If a send is retried after a timeout or rate limit, the security state should remain one reset request, not two unrelated credentials.

Also test the boring cases. A syntactically valid address may be suppressed. A request may receive HTTP 429. A message may be accepted and later bounce. The user may click twice, or click at 10 minutes and 1 second when your chosen lifetime is 10 minutes. Those are four different states, and a single sent: true flag can't represent them.

Rollout keeps the TypeScript adapter uncoupled

The following runnable example keeps provider details behind EmailGateway. It uses a 10-minute lifetime as an application choice, stores a SHA-256 digest instead of the raw token, and consumes the record before changing the password. Replace MemoryResetStore and CapturingEmailGateway with production adapters; the orchestration contract stays the same.

import { createHash, randomBytes, randomUUID } from "node:crypto";

type ResetRecord = {
  userId: string;
  digest: string;
  expiresAt: number;
  consumed: boolean;
};

interface ResetStore {
  put(record: ResetRecord): Promise<void>;
  consume(digest: string, now: number): Promise<ResetRecord | null>;
}

interface EmailGateway {
  sendPasswordReset(input: {
    requestId: string;
    to: string;
    resetUrl: string;
    expiresAt: string;
  }): Promise<void>;
}

class MemoryResetStore implements ResetStore {
  private readonly records = new Map<string, ResetRecord>();

  async put(record: ResetRecord): Promise<void> {
    this.records.set(record.digest, record);
  }

  async consume(digest: string, now: number): Promise<ResetRecord | null> {
    const record = this.records.get(digest);
    if (!record || record.consumed || record.expiresAt <= now) return null;
    record.consumed = true;
    return record;
  }
}

class CapturingEmailGateway implements EmailGateway {
  public lastMessage: Parameters<EmailGateway["sendPasswordReset"]>[0] | null = null;

  async sendPasswordReset(
    input: Parameters<EmailGateway["sendPasswordReset"]>[0],
  ): Promise<void> {
    this.lastMessage = input;
  }
}

const digest = (token: string): string =>
  createHash("sha256").update(token).digest("hex");

async function issuePasswordReset(
  store: ResetStore,
  email: EmailGateway,
  user: { id: string; email: string },
  now = Date.now(),
): Promise<string> {
  const token = randomBytes(32).toString("base64url");
  const expiresAt = now + 10 * 60 * 1_000;
  await store.put({ userId: user.id, digest: digest(token), expiresAt, consumed: false });
  await email.sendPasswordReset({
    requestId: randomUUID(),
    to: user.email,
    resetUrl: `https://app.example/reset?token=${encodeURIComponent(token)}`,
    expiresAt: new Date(expiresAt).toISOString(),
  });
  return token;
}

async function redeemPasswordReset(
  store: ResetStore,
  token: string,
  now = Date.now(),
): Promise<string> {
  const record = await store.consume(digest(token), now);
  if (!record) throw new Error("Reset token is expired, invalid, or already used");
  return record.userId;
}

const store = new MemoryResetStore();
const email = new CapturingEmailGateway();
const token = await issuePasswordReset(
  store,
  email,
  { id: "usr_67", email: "dev@example.com" },
  1_700_000_000_000,
);
console.log(await redeemPasswordReset(store, token, 1_700_000_300_000));
Enter fullscreen mode Exit fullscreen mode

Production storage needs an atomic compare-and-set or transaction for consume; the in-memory map merely makes the lifecycle executable. The email adapter should set an explicit HTTP method, authenticate from an environment variable rather than a literal key, reject non-success responses with their response body, and retry 429 responses with exponential backoff while honoring Retry-After. A write retry also needs the provider's documented idempotency mechanism tied to requestId. Don't guess the JSON payload from a marketing page. Generate the adapter from the current discovery schema or current provider documentation, then contract-test it.

This adapter is the provider-facing half. Set EMAIL_API_BASE_URL to the documented API base, put a request body validated against the current discovery schema in EMAIL_PAYLOAD_JSON, and run it with a real key in INFRAI_API_KEY. Keeping the payload external is deliberate: the available facts verify the route but do not supply its request fields, so inventing a friendly-looking object here would make the example unsafe to copy.

import { randomUUID } from "node:crypto";

const baseUrl = process.env.EMAIL_API_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;
const payloadText = process.env.EMAIL_PAYLOAD_JSON;

if (!baseUrl || !apiKey || !payloadText) {
  throw new Error(
    "Set EMAIL_API_BASE_URL, INFRAI_API_KEY, and EMAIL_PAYLOAD_JSON",
  );
}

const payload: unknown = JSON.parse(payloadText);

async function sendEmail(body: unknown): Promise<unknown> {
  const idempotencyKey = randomUUID();

  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch(`${baseUrl}/v1/email/send`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(body),
    });

    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));
      continue;
    }

    const responseBody: unknown = await response.json();
    if (!response.ok) {
      throw new Error(`Email API returned ${response.status}: ${JSON.stringify(responseBody)}`);
    }
    return responseBody;
  }

  throw new Error("Email API rate limit retry budget exhausted");
}

console.log(await sendEmail(payload));
Enter fullscreen mode Exit fullscreen mode

For monitoring, record request acceptance separately from later delivery events. Poll with a durable cursor or equivalent provider-supported position, deduplicate events, and expose poll lag, accepted sends, bounces, complaints, and suppressions as distinct signals. A crisp alert reads like this: polling is healthy, event age is rising, and bounce count changed after a deployment. That tells the responder where to look. A generic "email failed" counter does not.

The integration cost is delayed feedback, not the send call

This API-first shape is not suitable when an existing application requires SMTP relay. It is also a poor fit when webhook-driven event automation or immediate email-to-SMS fallback is mandatory, because email feedback is pull-only. Stick with a provider and architecture that supply the event timing your workflow requires in those cases.

There are more boundaries. Email has no hosted OTP endpoint, so an email-code fallback must be built in the application. Scheduled email has no cancellation operation. Voice, WhatsApp, and RCS are outside this capability. Mainland China compliance claims are out of scope because the Tencent email vendor remains pending; US and EU SaaS workflows are the supported decision frame here. Geographic anti-abuse controls and country-price circuit breakers for SMS also belong in the application layer.

None of those points makes the API-first approach bad. They define its lane. For a beginner-friendly US/EU transactional flow with direct sends, domain authentication, suppression handling, and event polling, it is coherent. For real-time orchestration across channels, choose a different event architecture.

That's the boundary.

References and further reading

Read RFC 6376 for the DKIM protocol details, then use the OWASP Forgot Password Cheat Sheet to review token generation, expiry, single use, and response behavior before shipping.

Top comments (0)