DEV Community

MidnightEcho794261
MidnightEcho794261

Posted on

Nodemailer Alternative: Sending Email OTPs in 2FA With Owned Templates

Short answer: use a replaceable mail transport behind application-owned templates, and keep email OTPs for 2FA separate from healthtech order receipts.

A Nodemailer alternative is the easy part of an OTP system. The harder decision is template ownership: keep authentication messages under an application-controlled contract, while treating a paid order receipt as a separate healthtech communication with its own approval, audit, and retry rules.

That split is the result of the design experiment here. A single mail helper looks efficient at first, but it couples security-sensitive expiry behavior to a business receipt that can be delayed, localized, or reissued. A generic delivery interface with owned templates keeps those policies independent. Before copying it, measure queue delay, code-send latency, retry volume, and template-change frequency in your own system.

How can a Nodemailer alternative handle sending email OTPs for 2FA?

They have different failure consequences. A late receipt is inconvenient and may need a resend. A late one-time passcode can block sign-in, while a duplicated or stale code can create confusing support cases. In a healthtech application, the receipt also carries order details that deserve narrower data handling than a six-digit challenge.

The application should own the message contract even when another component performs delivery. That contract can define the event name, recipient, locale, expiry deadline, and an opaque reference to the order. It should not hand a delivery provider a mutable order object and hope the right fields become an email.

Here is a deliberately small boundary. The transport is replaceable; the templates remain in the application repository and are reviewed with the code that emits them.

type MailMessage = {
  to: string;
  subject: string;
  text: string;
  html?: string;
  headers?: Record<string, string>;
};

type MailTransport = {
  send(message: MailMessage): Promise<{ id: string }>;
};

function buildOtpMessage(email: string, code: string, expiresAt: Date): MailMessage {
  return {
    to: email,
    subject: "Your sign-in code",
    text: `Your code is ${code}. It expires at ${expiresAt.toISOString()}.`,
    headers: {
      "X-Message-Class": "authentication"
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

The header is an internal classification, not a security control. The real controls are server-side verification, one-time use, rate limits, and an expiry check against a stored challenge.

What failed in the simple mail-helper approach?

The simple approach usually accepts a recipient and a string, then lets each caller assemble its own subject and body. That is convenient until an OTP caller inherits receipt wording, or a receipt retry accidentally reuses a challenge-oriented deduplication key. Template ownership has become implicit, so a harmless copy edit can change a security message without touching the authentication review path.

Keep it boring.

A better experiment is to make message classes explicit and reject cross-class data. The receipt builder receives a stable order summary, while the OTP builder receives a challenge. Neither receives the other class's complete record.

type OrderReceipt = {
  orderReference: string;
  paidAt: string;
  total: string;
  currency: string;
};

type OtpChallenge = {
  code: string;
  expiresAt: Date;
};

function buildReceiptMessage(email: string, order: OrderReceipt): MailMessage {
  return {
    to: email,
    subject: `Receipt for order ${order.orderReference}`,
    text: [
      `Order: ${order.orderReference}`,
      `Paid: ${order.paidAt}`,
      `Total: ${order.total} ${order.currency}`
    ].join("\n"),
    headers: { "X-Message-Class": "order-receipt" }
  };
}
Enter fullscreen mode Exit fullscreen mode

This is intentionally plain. A receipt should be renderable as text, and the application should decide exactly which order fields leave its boundary. Avoid putting the OTP itself, a reset token, or unnecessary clinical detail into the receipt payload.

How should delivery failures change the design?

Treat send acceptance and user-visible delivery as different states. A transport response can provide a message identifier, but it cannot prove that a person saw the email. Store the event with an idempotency key derived from the business event, then record transport outcomes separately. For an order receipt, a retry may be appropriate after a transient failure. For an OTP, generating a fresh challenge and invalidating the previous one is usually clearer than blindly retrying the same message.

The queue worker needs bounded retries, a dead-letter path, and structured logs that do not contain the code or full receipt contents. Log the message class, event reference, attempt number, and provider-neutral delivery identifier. Keep authentication and receipt metrics separate; otherwise a healthy receipt stream can hide a broken login path. The trade-off is operational work: an owned template contract needs review, fixtures, and migration tests when its fields change. That cost buys a clear audit trail rather than eliminating responsibility.

A practical test matrix has four cases: an expired OTP, a second request that supersedes the first, a receipt retry after a timeout, and a duplicate payment event. Each case should assert both the stored state and the messages that are permitted to leave the system.

What should be measured before replacing the transport?

The decision is not a brand comparison. It is a contract test. Run the same adapter tests against a local SMTP server, a direct SMTP relay, and an HTTP mail API. Check connection setup time, timeout behavior, response identifiers, attachment or HTML handling if receipts need them, and how each option exposes transient versus permanent failures. The interface should not pretend these behaviors are identical. SMTP's rules are described by RFC 5321, while the time-based OTP algorithm has a separate contract in RFC 6238; treating delivery and verification as one feature obscures both boundaries.

This architecture is a poor fit for a tiny internal tool that has no authentication workflow and sends occasional notices. A single direct sender may be enough there. The split earns its keep when a healthtech system must change receipt copy without changing challenge semantics.

For a solo team, the useful measurements are small and concrete: p50 and p95 time from challenge creation to accepted send, queue age, retry rate, invalid-recipient rate, and the percentage of template changes requiring an authentication review. Watch token and compute cost only where an LLM is involved in drafting or localization; generated copy must never be allowed to alter the fixed OTP contract at runtime.

The right replacement is the one that preserves the application-owned boundary while giving operations enough signal to diagnose delivery. A transport can change later. The ownership decision should not have to.

Sources

Top comments (0)