Back to Insights
Backend & Database

Setting Up Transactional Email with Node.js, SMTP, and PDF Attachments

A practical production tutorial on sending order confirmation emails with dynamic PDF receipt attachments using Node.js and Nodemailer. We cover SMTP transport selection, Outlook-proof HTML email templates, in-memory PDF generation with PDFKit, and exponential backoff retry logic for transient 4xx errors.

Muhammad Taki Ahmed
Muhammad Taki AhmedFounder & Software Developer at Raydrim
July 28, 2026
8 min read
Setting Up Transactional Email with Node.js, SMTP, and PDF Attachments

Choosing the Right Transport: Gmail vs SendGrid vs Resend

When you build an e-commerce checkout or SaaS billing pipeline, delivering an order confirmation into a customer's inbox within five seconds of payment is non-negotiable. If that confirmation lands in the spam folder or arrives fifteen minutes late, customer trust evaporates immediately.

In our client engineering projects at Raydrim, transactional email is routinely the subsystem developers treat as an afterthought until staging turns into production. The first architectural hurdle is transport selection. Most developers begin with personal SMTP credentials and quickly run into severe delivery and operational limits.

Here is how the three most common options compare in real production scenarios:

  • Gmail App Passwords: Google allows you to generate a 16-character App Password under 2-Step Verification for legacy SMTP clients connecting to smtp.gmail.com on port 465 (SSL) or 587 (TLS). While popular in quick tutorials, it fails catastrophically in production. Gmail enforces a strict 500 emails/day rolling limit (or 2,000/day on Google Workspace). Crucially, Google's heuristic abuse monitors aggressively throttle programmatic bursts. If you fire 30 order confirmations in 60 seconds during a product drop, Google will reject connections with 421 4.7.0 Try again later or lock the account entirely. Furthermore, your emails share IP pools with consumer webmail accounts, degrading deliverability.
  • Twilio SendGrid: The veteran enterprise workhorse. SendGrid provides reliable high-throughput infrastructure and dedicated IP pools. However, onboarding friction has escalated significantly. Automated fraud-detection algorithms frequently suspend legitimate developer accounts during setup without human explanation. Additionally, unless you pay for an expensive dedicated IP ($89+/month) and execute a multi-week manual IP warmup schedule, you remain on shared IPs where other noisy senders can drag down your sender reputation.
  • Resend: The modern developer-standard service built directly on top of AWS Simple Email Service (SES). Resend provides clean REST and SMTP credentials (smtp.resend.com), instant 1-click DKIM, SPF, and DMARC verification via DNS records, excellent shared IP reputation pools, and real-time delivery logs. For 95% of modern Node.js and Next.js applications, Resend or direct AWS SES is the objectively correct choice.
Provider Daily Volume Cap DNS / DKIM Setup Delivery Latency Production Suitability
Gmail App Passwords 500 / day (Hard) Shared consumer DNS 2.5s – 6.0s Development Only
Twilio SendGrid 100 / day (Free) • Scaled (Paid) Custom CNAME & SPF 1.2s – 2.8s Enterprise / High Volume
Resend 100 / day (Free) • 50k+ (Paid) Automated DKIM & SPF 400ms – 900ms Recommended Default

Bulletproof HTML: Designing Receipts That Render Across Clients

Authoring HTML for email is fundamentally different from building web applications. Modern CSS features that frontend developers take for granted — CSS Grid, Flexbox, CSS variables, and external stylesheets — will break across legacy mail clients.

The primary rendering obstacles include:

  • Microsoft Outlook (Windows Desktop 2016–365): Uses Microsoft Word's legacy WordHTML rendering engine. It completely ignores display: flex, collapses margins on divs, and drops padding on inline elements.
  • Gmail (Web & Mobile): Strips out the entire <style> block inside your document head if it encounters a single invalid selector. Gmail also strips out any CSS class that is not explicitly inlined on the element.
  • Apple Mail & iOS Mail: Uses the modern WebKit engine, but aggressive dark mode heuristics can invert brand colors or turn dark charcoal text into unreadable low-contrast gray unless styled defensively.

To ensure universal compatibility across Outlook, Gmail, and mobile clients, transactional emails must rely on nested <table> elements with explicit attributes: cellpadding="0" cellspacing="0" border="0", centered with a maximum width of 580px to 600px, and styled using strict inline CSS.

Here is our production HTML receipt generator function in TypeScript:

export interface OrderItem {
  name: string;
  quantity: number;
  unitPrice: number;
}

export interface OrderConfirmation {
  orderNumber: string;
  customerName: string;
  customerEmail: string;
  items: OrderItem[];
  subtotal: number;
  tax: number;
  shipping: number;
  total: number;
  date: string;
}

export function renderReceiptEmailHtml(order: OrderConfirmation): string {
  const itemRowsHtml = order.items
    .map(
      (item) =>
        '<tr>' +
        '<td style="padding: 12px 0; border-bottom: 1px solid #e2e8f0; font-size: 14px; color: #1e293b;">' +
        '<strong>' + item.name + '</strong> × ' + item.quantity +
        '</td>' +
        '<td style="padding: 12px 0; border-bottom: 1px solid #e2e8f0; font-size: 14px; text-align: right; color: #0f172a; font-weight: 600;">' +
        '$' + (item.unitPrice * item.quantity).toFixed(2) +
        '</td>' +
        '</tr>'
    )
    .join('');

  return (
    '<!DOCTYPE html>' +
    '<html lang="en">' +
    '<head>' +
    '<meta charset="utf-8">' +
    '<meta name="viewport" content="width=device-width, initial-scale=1.0">' +
    '<title>Order Confirmation #' + order.orderNumber + '</title>' +
    '</head>' +
    '<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">' +
    '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #f8fafc; padding: 32px 16px;">' +
    '<tr><td align="center">' +
    '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width: 580px; background-color: #ffffff; border-radius: 8px; border: 1px solid #e2e8f0; overflow: hidden;">' +
    '<tr><td style="background-color: #0f172a; padding: 24px; text-align: center;">' +
    '<h1 style="color: #ffffff; margin: 0; font-size: 20px; letter-spacing: 0.5px;">RAYDRIM ORDER CONFIRMATION</h1>' +
    '</td></tr>' +
    '<tr><td style="padding: 32px 28px;">' +
    '<p style="font-size: 15px; color: #334155; margin-top: 0;">Hi ' + order.customerName + ',</p>' +
    '<p style="font-size: 15px; color: #334155; line-height: 1.5;">Thank you for your purchase! We have confirmed payment for order <strong>#' + order.orderNumber + '</strong>. An official itemized PDF receipt is attached to this email for your accounting records.</p>' +
    '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-top: 24px; margin-bottom: 24px;">' +
    itemRowsHtml +
    '<tr><td style="padding: 8px 0; font-size: 14px; color: #64748b;">Subtotal</td><td style="padding: 8px 0; font-size: 14px; text-align: right; color: #334155;">$' + order.subtotal.toFixed(2) + '</td></tr>' +
    '<tr><td style="padding: 8px 0; font-size: 14px; color: #64748b;">Estimated Tax</td><td style="padding: 8px 0; font-size: 14px; text-align: right; color: #334155;">$' + order.tax.toFixed(2) + '</td></tr>' +
    '<tr><td style="padding: 8px 0; font-size: 14px; color: #64748b;">Shipping</td><td style="padding: 8px 0; font-size: 14px; text-align: right; color: #334155;">$' + order.shipping.toFixed(2) + '</td></tr>' +
    '<tr><td style="padding: 12px 0 0; font-size: 16px; font-weight: 700; color: #0f172a; border-top: 2px solid #e2e8f0;">Total Paid</td><td style="padding: 12px 0 0; font-size: 16px; font-weight: 700; text-align: right; color: #0f172a; border-top: 2px solid #e2e8f0;">$' + order.total.toFixed(2) + '</td></tr>' +
    '</table>' +
    '<div style="text-align: center; margin-top: 28px;">' +
    '<a href="https://raydrim.com/account/orders/' + order.orderNumber + '" style="background-color: #0f172a; color: #ffffff; text-decoration: none; padding: 12px 24px; border-radius: 6px; font-size: 14px; font-weight: 600; display: inline-block;">View Order in Dashboard &rarr;</a>' +
    '</div>' +
    '</td></tr>' +
    '<tr><td style="background-color: #f8fafc; padding: 20px; text-align: center; border-top: 1px solid #e2e8f0; font-size: 12px; color: #94a3b8;">' +
    'Raydrim Technologies • House 42, Road 11, Banani, Dhaka 1213<br>' +
    'Need assistance? Contact support@raydrim.com' +
    '</td></tr>' +
    '</table>' +
    '</td></tr></table>' +
    '</body></html>'
  );
}

Zero-Disk PDF Receipts: Streaming Invoices with PDFKit

One of the most dangerous patterns in backend services is saving dynamically generated files to local disk using fs.writeFileSync('/tmp/receipt.pdf'). In containerized microservices and serverless runtimes (AWS Lambda, Vercel, Google Cloud Run), disk-based file generation introduces severe architectural liabilities:

  • Read-only filesystems: Modern cloud runtimes enforce read-only filesystems with the exception of an ephemeral /tmp scratchpad.
  • Concurrency race conditions: If two simultaneous checkout webhooks write to the same file path, customer data gets leaked or corrupted across threads.
  • File descriptor leaks: If an unhandled exception triggers before disk cleanup scripts run, the host runs out of inodes and halts.
  • Headless Chrome bloat: Reaching for Puppeteer or Playwright to convert HTML to PDF requires bundling a 300MB+ Chromium binary, consumes 800MB+ of RAM, and adds 2 to 4 seconds of cold-start latency.

By contrast, PDFKit is a pure JavaScript vector generation engine. It has zero native binary dependencies, runs in under 20 milliseconds, uses less than 12MB of RAM, and pipes binary chunks directly into an in-memory Node.js Buffer.

Here is the implementation that compiles an itemized PDF invoice entirely in memory:

import PDFDocument from 'pdfkit';

export async function generateInvoicePdf(order: OrderConfirmation): Promise<Buffer> {
  return new Promise((resolve, reject) => {
    const doc = new PDFDocument({
      size: 'A4',
      margin: 50,
      info: {
        Title: 'Invoice #' + order.orderNumber,
        Author: 'Raydrim Technologies',
        Subject: 'Official Tax Invoice',
      },
    });

    const chunks: Buffer[] = [];
    doc.on('data', (chunk: Buffer) => chunks.push(chunk));
    doc.on('end', () => resolve(Buffer.concat(chunks)));
    doc.on('error', (err) => reject(err));

    // Brand Header
    doc
      .fontSize(20)
      .fillColor('#0f172a')
      .text('RAYDRIM DIGITAL', 50, 50, { align: 'left' })
      .fontSize(10)
      .fillColor('#64748b')
      .text('Official Payment Receipt', 50, 75);

    // Meta details (Right aligned)
    doc
      .fontSize(10)
      .fillColor('#334155')
      .text('Invoice #: ' + order.orderNumber, 350, 50, { align: 'right' })
      .text('Date: ' + order.date, 350, 65, { align: 'right' })
      .text('Status: PAID', 350, 80, { align: 'right' });

    // Divider Line
    doc
      .strokeColor('#e2e8f0')
      .lineWidth(1)
      .moveTo(50, 105)
      .lineTo(545, 105)
      .stroke();

    // Customer Information
    doc
      .fontSize(11)
      .fillColor('#0f172a')
      .text('Billed To:', 50, 120)
      .fontSize(10)
      .fillColor('#475569')
      .text(order.customerName, 50, 136)
      .text(order.customerEmail, 50, 150);

    // Table Header Bar
    let yPos = 185;
    doc.rect(50, yPos, 495, 22).fill('#f1f5f9');

    doc
      .fontSize(9)
      .fillColor('#1e293b')
      .text('ITEM DESCRIPTION', 60, yPos + 6)
      .text('QTY', 340, yPos + 6, { width: 40, align: 'center' })
      .text('PRICE', 390, yPos + 6, { width: 60, align: 'right' })
      .text('TOTAL', 460, yPos + 6, { width: 75, align: 'right' });

    yPos += 26;

    // Line Items
    order.items.forEach((item) => {
      const lineTotal = item.unitPrice * item.quantity;
      doc
        .fontSize(9)
        .fillColor('#334155')
        .text(item.name, 60, yPos)
        .text(String(item.quantity), 340, yPos, { width: 40, align: 'center' })
        .text('$' + item.unitPrice.toFixed(2), 390, yPos, { width: 60, align: 'right' })
        .text('$' + lineTotal.toFixed(2), 460, yPos, { width: 75, align: 'right' });

      yPos += 20;
    });

    // Subtotal & Tax Calculation
    yPos += 10;
    doc
      .strokeColor('#e2e8f0')
      .lineWidth(1)
      .moveTo(330, yPos)
      .lineTo(545, yPos)
      .stroke();

    yPos += 8;
    const addTotalRow = (label: string, value: string, isBold = false) => {
      doc
        .fontSize(isBold ? 10 : 9)
        .fillColor(isBold ? '#0f172a' : '#64748b')
        .text(label, 330, yPos, { width: 110, align: 'left' })
        .text(value, 450, yPos, { width: 85, align: 'right' });
      yPos += 16;
    };

    addTotalRow('Subtotal:', '$' + order.subtotal.toFixed(2));
    addTotalRow('Tax (8.25%):', '$' + order.tax.toFixed(2));
    addTotalRow('Shipping:', '$' + order.shipping.toFixed(2));
    yPos += 2;
    addTotalRow('Total Paid:', '$' + order.total.toFixed(2), true);

    // Footer Audit Note
    doc
      .fontSize(8)
      .fillColor('#94a3b8')
      .text(
        'Thank you for choosing Raydrim. This document serves as an official electronic receipt.',
        50,
        740,
        { align: 'center', width: 495 }
      );

    doc.end();
  });
}

Configuring Nodemailer with Connection Pooling and Attachments

By default, calling nodemailer.createTransport() establishes a brand new TCP connection, negotiates TLS, performs the SMTP handshake (EHLO), authenticates, sends the message, and tears down the socket for every dispatch. If your application handles a burst of orders, this introduces 500ms to 1,200ms of latency per email and risks exceeding the SMTP host's concurrent handshake threshold.

The solution is enabling Nodemailer's built-in connection pool. A pooled transporter keeps authenticated TCP sockets open and reuses them for consecutive messages.

Here is the pooled transporter setup and dispatch handler:

import nodemailer from 'nodemailer';

export interface MailerConfig {
  host: string;
  port: number;
  secure: boolean;
  user: string;
  pass: string;
  from: string;
}

export function createPooledTransporter(config: MailerConfig): nodemailer.Transporter {
  return nodemailer.createTransport({
    host: config.host,
    port: config.port,
    secure: config.secure, // true for port 465, false for port 587
    auth: {
      user: config.user,
      pass: config.pass,
    },
    pool: true,
    maxConnections: 5,
    maxMessages: 100,
    rateLimit: 14, // Cap at 14 messages/second to stay within provider limits
  });
}

export async function sendOrderConfirmationReceipt(
  transporter: nodemailer.Transporter,
  config: MailerConfig,
  order: OrderConfirmation
): Promise<{ messageId: string }> {
  // 1. Generate universal HTML email body
  const htmlContent = renderReceiptEmailHtml(order);

  // 2. Generate vector PDF in-memory (Buffer)
  const pdfBuffer = await generateInvoicePdf(order);

  // 3. Dispatch through pooled SMTP connection
  const info = await transporter.sendMail({
    from: config.from,
    to: '"' + order.customerName + '" <' + order.customerEmail + '>',
    subject: 'Order Confirmed #' + order.orderNumber + ' - Your Receipt',
    text: 'Thank you for your order #' + order.orderNumber + '. Your PDF receipt is attached.',
    html: htmlContent,
    attachments: [
      {
        filename: 'Receipt-' + order.orderNumber + '.pdf',
        content: pdfBuffer,
        contentType: 'application/pdf',
      },
    ],
  });

  return { messageId: info.messageId };
}

Resilience in Production: 4xx vs 5xx Errors and Exponential Backoff

SMTP error responses provide explicit signaling on whether an operation should be retried. Treating every failure identically inside a naive catch block will quickly destroy your domain's delivery reputation.

SMTP errors are divided into two fundamental classes:

  • Transient Failures (4xx Status Codes): Codes like 421 Service not available, 450 Mailbox busy, 451 Local processing error, or network timeouts (ETIMEDOUT, ECONNRESET). These mean the receiving mail server is experiencing temporary traffic spikes or rate-limiting your connection. These must be retried with exponential backoff and randomized jitter.
  • Permanent Failures (5xx Status Codes): Codes like 550 Mailbox unavailable (user does not exist), 551 User not local, or 554 Transaction failed. The mailbox does not exist or the receiving domain has rejected your address. Retrying 5xx errors will destroy your domain reputation and trigger Spamhaus blacklisting. Abort immediately and record the bounce in your database.

Here is a production retry wrapper implementing exponential backoff with jitter and error discrimination:

interface SmtpError extends Error {
  responseCode?: number;
  code?: string;
}

export async function sendWithExponentialBackoff<T>(
  operation: () => Promise<T>,
  maxRetries = 3,
  baseDelayMs = 1000
): Promise<T> {
  let attempt = 0;

  while (attempt < maxRetries) {
    try {
      return await operation();
    } catch (err: unknown) {
      attempt++;
      const smtpErr = err as SmtpError;
      const code = smtpErr.responseCode || 0;

      // Abort immediately on permanent 5xx rejection
      if (code >= 500 && code < 600) {
        console.error('Permanent SMTP rejection (' + code + '). Aborting retries.');
        throw smtpErr;
      }

      if (attempt >= maxRetries) {
        console.error('SMTP retries exhausted after ' + maxRetries + ' attempts.');
        throw smtpErr;
      }

      // Calculate exponential backoff with jitter
      const jitter = Math.floor(Math.random() * 250);
      const delay = baseDelayMs * Math.pow(2, attempt - 1) + jitter;

      console.warn(
        'Transient SMTP failure (' + (smtpErr.code || code) + '). Retrying in ' + delay + 'ms...'
      );
      await new Promise((res) => setTimeout(res, delay));
    }
  }

  throw new Error('Unexpected retry termination');
}

Transactional Email Production Architecture Checklist

Before launching transactional billing or order emails to paying customers, verify your deployment against this checklist:

  • Authenticate DNS Records: Configure SPF (v=spf1 include:... ~all), DKIM (2048-bit keys), and DMARC (v=DMARC1; p=quarantine; rua=...). Unauthenticated emails are discarded by Google and Yahoo mailboxes under modern sender requirements.
  • Generate Attachments In-Memory: Never write temporary files to disk in serverless runtimes. Pass in-memory binary buffers directly from PDFKit to Nodemailer.
  • Enable Transporter Pooling: Always configure pool: true with sensible maxConnections and rateLimit parameters to eliminate TLS handshake overhead.
  • Separate Transactional and Marketing Domains: Never send promotional blasts from the domain used for purchase receipts and password resets. Use dedicated subdomains like notifications.raydrim.com.
  • Implement Dead-Letter Queues (DLQ): When transient 4xx retries are exhausted, push the failed order payload into a persistent queue (Redis BullMQ or PostgreSQL) for operational alerting and manual replay.
#Node.js#SMTP#Email#PDF#Nodemailer
Share this article
Muhammad Taki Ahmed

Written by Muhammad Taki Ahmed

Founder & Software Developer at Raydrim

Muhammad Taki Ahmed is a full-stack developer based in Dhaka, Bangladesh. He builds production web applications with Next.js, React and TypeScript, and writes about what actually broke while shipping them.

Related Technical Insights