ZyVOP Logo
Content That Connects
SeriesCategoriesTags
ZyVOP Logo
Content That Connects

Empowering developers and creators with cutting-edge insights, comprehensive tutorials, and innovative solutions for the digital future.

Content

  • Tags
  • Write Article

Company

  • About Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

© 2026 ZyVOP. Crafted with care for the developer community.

Made with ❤️ by the ZyVOP team
All systems operational
HomeTransactional Email in Node.js: Delivery, Templates, and the Mistakes That Land You in Spam

Transactional Email in Node.js: Delivery, Templates, and the Mistakes That Land You in Spam

Provider selection, React Email templates, bounce handling, and the SPF/DKIM/DMARC setup that determines whether your emails reach inboxes

#Node.js transactional email#Resend Node.js#React Email template#email deliverability 2026#SPF DKIM DMARC setup#email bounce handling Node.js#BullMQ email queue#transactional email best practices
Z
ZyVOP

Senior Developer

May 26, 2026
9 min read
5 views
Transactional Email in Node.js: Delivery, Templates, and the Mistakes That Land You in Spam

Sending email looks simple. You call an API, pass a subject and body, and it goes. Then you notice your password reset emails are landing in spam. Your welcome emails are being soft-bounced. Your invoice delivery rate is 74% and nobody told you. And the developer who set up the email integration six months ago used a free tier API key that is now rate-limited.

Transactional email is deceptively operational. This guide covers the full implementation: provider selection, sending with proper error handling, HTML templates that actually render, bounce and complaint handling, and the delivery fundamentals that determine whether your emails reach inboxes.


Choosing a Provider

The choice comes down to who owns delivery risk and how much configuration you want to manage.

Postmark — purpose-built for transactional email. Strict content policies (no bulk or marketing), dedicated IP pools per account, consistently the best inbox placement rates in independent tests. More expensive but the right choice if deliverability is non-negotiable.

Resend — the newest entrant, built by developers for developers. Clean API, excellent TypeScript SDK, React Email integration built in. Strong deliverability, growing fast, good free tier. The best starting point for most new projects in 2026.

AWS SES — cheapest at scale ($0.10 per 1,000 emails). Requires the most configuration: you manage your own sending reputation, suppression lists, bounce handling, and feedback loops. Right choice if your stack is already AWS-native and you have the operational bandwidth.

SendGrid — dominant in the enterprise market, handles both transactional and marketing. More complex to set up, slightly lower deliverability in independent tests (61% inbox placement vs Postmark's 95%+). Good if you need marketing email from the same platform.

This guide uses Resend for the implementation — clean API, first-class TypeScript, and React Email support. The patterns apply to any provider.


Setup

npm install resend react @react-email/components
// src/lib/email.ts
import { Resend } from 'resend';

export const resend = new Resend(process.env.RESEND_API_KEY);

export const FROM_ADDRESS = {
  transactional: 'Your App <noreply@yourdomain.com>',
  support:       'Your App Support <support@yourdomain.com>',
  billing:       'Your App Billing <billing@yourdomain.com>',
};

React Email: Templates That Do Not Break in Outlook

HTML email is a different world. CSS support varies wildly across clients. Outlook uses Word's rendering engine. Gmail strips certain styles. Dark mode inverts colors unpredictably. React Email handles this — it generates battle-tested inline HTML from React components.

npm install @react-email/components
// src/emails/WelcomeEmail.tsx
import {
  Html, Head, Preview, Body, Container,
  Heading, Text, Button, Hr, Img,
} from '@react-email/components';

interface WelcomeEmailProps {
  userName:    string;
  confirmUrl:  string;
  appName:     string;
}

export function WelcomeEmail({ userName, confirmUrl, appName }: WelcomeEmailProps) {
  return (
    <Html>
      <Head />
      <Preview>Welcome to {appName} — confirm your email to get started</Preview>
      <Body style={styles.body}>
        <Container style={styles.container}>
          <Heading style={styles.heading}>
            Welcome to {appName}, {userName}
          </Heading>

          <Text style={styles.text}>
            Thanks for signing up. Click the button below to confirm your email
            address and activate your account.
          </Text>

          <Button href={confirmUrl} style={styles.button}>
            Confirm Email Address
          </Button>

          <Hr style={styles.hr} />

          <Text style={styles.footer}>
            If you did not create an account, you can safely ignore this email.
            This link expires in 24 hours.
          </Text>
        </Container>
      </Body>
    </Html>
  );
}

const styles = {
  body: {
    backgroundColor: '#f6f9fc',
    fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
  },
  container: {
    backgroundColor: '#ffffff',
    margin: '0 auto',
    padding: '40px 20px',
    maxWidth: '560px',
    borderRadius: '8px',
  },
  heading: {
    color: '#1a1a1a',
    fontSize: '24px',
    fontWeight: '600',
    marginBottom: '16px',
  },
  text: {
    color: '#444444',
    fontSize: '16px',
    lineHeight: '24px',
  },
  button: {
    backgroundColor: '#0070f3',
    borderRadius: '6px',
    color: '#ffffff',
    display: 'block',
    fontSize: '16px',
    fontWeight: '600',
    padding: '12px 24px',
    textAlign: 'center' as const,
    textDecoration: 'none',
  },
  hr: {
    borderColor: '#e6ebf1',
    margin: '24px 0',
  },
  footer: {
    color: '#8898aa',
    fontSize: '14px',
    lineHeight: '20px',
  },
};

The Email Service Layer

Never call the email provider directly from route handlers. Keep all email logic in a service layer:

// src/services/emailService.ts
import { render } from '@react-email/render';
import { resend, FROM_ADDRESS } from '../lib/email';
import { WelcomeEmail } from '../emails/WelcomeEmail';
import { PasswordResetEmail } from '../emails/PasswordResetEmail';
import { OrderConfirmationEmail } from '../emails/OrderConfirmationEmail';
import logger from '../lib/logger';

type EmailResult =
  | { success: true; messageId: string }
  | { success: false; error: string };

async function send(params: {
  to:       string | string[];
  from:     string;
  subject:  string;
  html:     string;
  text:     string;
  tags?:    { name: string; value: string }[];
}): Promise<EmailResult> {
  try {
    const { data, error } = await resend.emails.send(params);

    if (error) {
      logger.error({ error, to: params.to }, 'Email send failed');
      return { success: false, error: error.message };
    }

    logger.info({ messageId: data?.id, to: params.to }, 'Email sent');
    return { success: true, messageId: data!.id };

  } catch (err: any) {
    logger.error({ error: err.message, to: params.to }, 'Email send threw');
    return { success: false, error: err.message };
  }
}

// ─── Public email functions ───────────────────────────────

export async function sendWelcomeEmail(params: {
  to:         string;
  userName:   string;
  confirmUrl: string;
}): Promise<EmailResult> {
  const html = await render(
    WelcomeEmail({
      userName:   params.userName,
      confirmUrl: params.confirmUrl,
      appName:    'Your App',
    })
  );

  // Always generate a plain text version for accessibility and deliverability
  const text = `
Welcome to Your App, ${params.userName}!

Confirm your email: ${params.confirmUrl}

This link expires in 24 hours.
  `.trim();

  return send({
    to:      params.to,
    from:    FROM_ADDRESS.transactional,
    subject: 'Confirm your email address',
    html,
    text,
    tags: [{ name: 'type', value: 'welcome' }],
  });
}

export async function sendPasswordResetEmail(params: {
  to:       string;
  userName: string;
  resetUrl: string;
}): Promise<EmailResult> {
  const html = await render(
    PasswordResetEmail({
      userName: params.userName,
      resetUrl: params.resetUrl,
    })
  );

  const text = `
Hi ${params.userName},

Reset your password: ${params.resetUrl}

This link expires in 1 hour. If you didn't request a reset, ignore this email.
  `.trim();

  return send({
    to:      params.to,
    from:    FROM_ADDRESS.transactional,
    subject: 'Reset your password',
    html,
    text,
    tags: [{ name: 'type', value: 'password-reset' }],
  });
}

Queuing Emails (Never Send Inline)

Email sends should go through BullMQ, not happen inline in request handlers. A slow provider, a network hiccup, or a rate limit should never cause a 500 on your signup endpoint.

// src/queues/emailQueue.ts
import { emailQueue } from './index';

// From your auth route — kick off and return, do not await the send
router.post('/auth/register', async (req, res) => {
  const user = await createUser(req.body);

  await emailQueue.add('welcome', {
    to:         user.email,
    userName:   user.fullName,
    confirmUrl: `https://yourapp.com/confirm?token=${user.confirmToken}`,
  }, {
    jobId:    `welcome-${user.id}`,    // Deduplication
    attempts: 3,
    backoff:  { type: 'exponential', delay: 5000 },
  });

  res.status(201).json({ id: user.id });
});
// src/workers/emailWorker.ts
import { Worker } from 'bullmq';
import * as emailService from '../services/emailService';
import { bullMQConnection } from '../lib/redis';

const worker = new Worker('email', async (job) => {
  switch (job.name) {
    case 'welcome':
      return emailService.sendWelcomeEmail(job.data);

    case 'password-reset':
      return emailService.sendPasswordResetEmail(job.data);

    case 'order-confirmation':
      return emailService.sendOrderConfirmationEmail(job.data);

    default:
      throw new Error(`Unknown email job: ${job.name}`);
  }
}, {
  connection:  bullMQConnection,
  concurrency: 5,
  limiter: {
    max:      10,    // Max 10 emails per second
    duration: 1000,
  },
});

Bounce and Complaint Handling

This is the part most tutorials skip. If you do not handle bounces and complaints, your sending reputation degrades silently until your emails stop arriving in inboxes at all.

Hard bounce — the email address does not exist. Stop sending to it immediately. Forever.

Soft bounce — temporary failure (mailbox full, server down). Retry a few times, then stop.

Complaint — the recipient clicked "Report Spam." Stop sending to them immediately. Continuing to send to complainers destroys your domain reputation.

Most providers deliver these events via webhook:

// src/routes/webhooks/resend.ts
router.post('/webhooks/resend', async (req, res) => {
  // Verify the webhook signature (Resend signs its webhooks)
  const signature = req.headers['resend-signature'];
  if (!verifyResendSignature(req.body, signature)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  res.status(200).json({ received: true }); // Acknowledge immediately

  const event = JSON.parse(req.body.toString());

  switch (event.type) {
    case 'email.bounced': {
      const { to, bounce_type } = event.data;
      if (bounce_type === 'hard') {
        // Hard bounce — mark as permanently undeliverable
        await db.query(
          `INSERT INTO email_suppressions (email, reason, created_at)
           VALUES ($1, 'hard_bounce', NOW())
           ON CONFLICT (email) DO NOTHING`,
          [to]
        );
        logger.warn({ email: to }, 'Hard bounce — added to suppression list');
      }
      break;
    }

    case 'email.complained': {
      const { to } = event.data;
      await db.query(
        `INSERT INTO email_suppressions (email, reason, created_at)
         VALUES ($1, 'complaint', NOW())
         ON CONFLICT (email) DO NOTHING`,
        [to]
      );
      logger.warn({ email: to }, 'Spam complaint — added to suppression list');
      break;
    }
  }
});

Check the suppression list before every send:

// In your email service, before calling resend.emails.send:
async function isEmailSuppressed(email: string): Promise<boolean> {
  const result = await db.query(
    'SELECT 1 FROM email_suppressions WHERE email = $1',
    [email.toLowerCase()]
  );
  return result.rows.length > 0;
}

// In the send function:
if (await isEmailSuppressed(params.to as string)) {
  logger.info({ email: params.to }, 'Email suppressed — skipping send');
  return { success: true, messageId: 'suppressed' };
}

The Delivery Fundamentals

These are the configuration steps that determine whether your emails reach inboxes. Do them once, verify them, and stop worrying about deliverability.

SPF record — tells receiving servers which IPs are allowed to send email for your domain. Add to your DNS:

TXT @ "v=spf1 include:_spf.resend.com ~all"

DKIM — cryptographically signs outgoing emails so receivers can verify they came from you. Your provider generates the keys; you add the DNS record they give you. Without DKIM, Gmail and others treat your emails with suspicion.

DMARC — tells receivers what to do with emails that fail SPF or DKIM. Start in report-only mode:

TXT _dmarc "v=DMARC1; p=none; rua=mailto:dmarc-reports@yourdomain.com"

After a week, check the reports. Once you confirm legitimate mail is passing, move to enforcement:

TXT _dmarc "v=DMARC1; p=quarantine; pct=100; rua=mailto:dmarc-reports@yourdomain.com"

Custom sending domain — sending from noreply@yourdomain.com via your provider's infrastructure (not noreply@resend.com or noreply@sendgrid.net) builds domain reputation over time and looks professional.

Verify your setup:

# Check SPF
dig TXT yourdomain.com | grep spf

# Check DMARC
dig TXT _dmarc.yourdomain.com

# Use MXToolbox for full analysis
# https://mxtoolbox.com/emailhealth/yourdomain.com

The Suppression Table Schema

CREATE TABLE email_suppressions (
  email      TEXT PRIMARY KEY,
  reason     TEXT NOT NULL CHECK (reason IN ('hard_bounce', 'complaint', 'unsubscribe', 'manual')),
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_suppressions_email ON email_suppressions(email);

What a 74% Delivery Rate Actually Means

If 26% of your transactional emails are not arriving, you are losing 26% of password resets, 26% of order confirmations, and 26% of welcome emails. Every one of those is a user who thinks your product is broken.

The causes, in order of likelihood: missing or misconfigured DKIM, no suppression list management causing complaint accumulation, sending from a shared IP pool with poor neighborhood reputation, or using a free-tier account that shares infrastructure with spammers.

Fix the DNS records, handle bounces and complaints, use a dedicated IP if your volume justifies it, and measure your inbox placement rate with a tool like GlockApps or MailReach. Deliverability is not a "set it and forget it" problem — it degrades slowly and silently if you stop paying attention.

Z

ZyVOP

Passionate developer sharing knowledge about modern web technologies and best practices.

Comments (0)

Login to post a comment.

Stay Updated

Get the latest articles delivered to your inbox.

We respect your privacy. Unsubscribe anytime.

Popular Tags

#.env.example Node.js#0x profiling#12-factor#AI agents#AI code security#AI coding tools 2026#AI-assisted development#AI-generated vulnerabilities#ALTER TABLE no lock#API Design