Email in MERN Apps: The Complete Mental Model

Search for a command to run...

No comments yet. Be the first to comment.
Just imagine that you are a Major at the Indian Army Command Center. You have a very critical mission at this time and the mission is to Capture the Enemy's Base. In early days we used Callbacks. Take

What is a Browser, Really? Beyond just "opening websites," a browser is a specialized piece of software designed to request, retrieve, and render content from the internet. It takes raw text (HTML/CSS) and transforms it into a visual experience you c...

How do we tell the browser to make a specific heading orange or a specific paragraph bold? This is where CSS Selectors come in. Think of selectors as a way to "point" at the elements you want to style. Why Do We Need Selectors? Imagine you are in a b...

Behind the every beautiful website HTML plays a crucial role by builing the cor structure of the site. HTML (HyperText Markup Language) works as the Skeleton of a website. It does not handle the styling and logic part of the site but makes the entire...

A from-scratch guide to how form submissions turn into real emails, how to choose a provider, and how to build it like a professional.
Before any code, get this shape into your head. Every single email feature you will ever build (verification, password reset, order confirmation, "new post published") is the same shape, just with different triggers and templates.
┌──────────┐ 1. submit ┌──────────┐ 3. save/validate ┌──────────┐
│ Browser │ ──────────────────▶ │ Express │ ──────────────────────▶ │ MongoDB │
│ (React) │ │ Backend │ │ │
│ │ ◀────────────────── │ │ ◀────────────────────── │ │
└──────────┘ 6. HTTP response └────┬─────┘ 4. saved doc └──────────┘
│
│ 2. after DB write succeeds,
│ call sendEmail()
▼
┌──────────────┐ 5. SMTP or HTTPS API ┌──────────────┐
│ Email Service │ ───────────────────────▶│ Recipient's │
│ (Resend, SES, │ │ mail server │
│ SendGrid...) │ │ (Gmail, etc.) │
└──────────────┘ └──────────────┘
The critical insight most beginners miss: your Express server never talks directly to Gmail or Outlook. It talks to a third-party email service (or an SMTP relay), and that service is the one that actually negotiates delivery with the recipient's mail server. You are always one hop removed from the inbox. Everything about deliverability, spam folders, and DNS records exists because of that hop.
Keep this diagram in mind — every section below zooms into one arrow of it.
Let's trace one concrete request: a user submits a "Contact Us" form.
// client/src/components/ContactForm.jsx
const [formData, setFormData] = useState({ name: '', email: '', message: '' });
const handleSubmit = async (e) => {
e.preventDefault();
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
const data = await res.json();
};
At this point the data exists only in the browser's memory (React state). Nothing has been validated, saved, or sent anywhere. This is the layer people wrongly trust the most — never validate only here. Anyone can open dev tools and send whatever they want directly to your API, bypassing your form entirely.
fetch turns your JS object into an HTTP POST request with a JSON body. It travels over the internet (or localhost during dev) to your Express server. This is a stateless, one-shot request — the server has no memory of who you are unless you send a cookie or token.
// server/routes/contactRoutes.js
router.post('/contact', contactController.submitContact);
// server/controllers/contactController.js
const { body, validationResult } = require('express-validator');
exports.submitContact = [
body('email').isEmail().normalizeEmail(),
body('name').trim().isLength({ min: 1, max: 100 }).escape(),
body('message').trim().isLength({ min: 1, max: 2000 }).escape(),
async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { name, email, message } = req.body;
// ... proceed
},
];
This is the real validation layer. The frontend validation is for user experience (instant feedback); the backend validation is for security. Never skip the backend check just because the frontend already checked.
const submission = await ContactSubmission.create({ name, email, message });
Not every email needs a DB write first (a plain contact form might not need to be stored at all), but registration, subscriptions, and orders almost always do — you need a durable record of what happened independent of whether the email succeeds.
await emailService.sendContactNotification({ name, email, message });
res.status(201).json({ message: 'Submitted successfully' });
This ordering matters a lot, and is one of the most common architectural mistakes beginners make:
Always persist the source-of-truth record before attempting to send an email. If the email fails, you still have the data and can retry later. If you send the email first and the DB write fails, you've told a user "you're registered!" when they aren't.
Your Express code doesn't open a socket to the recipient's mail server. It calls a library (Nodemailer) or an HTTP API (Resend, SendGrid, etc.), and that service handles the real delivery, retries, bounce tracking, and reputation management.
Express sends back JSON; React updates the UI ("Thanks, we'll be in touch"). Note: this response should not wait forever on the email actually landing in the inbox — more on this in the "architecture patterns" section below.
This is the part almost no tutorial explains, and it's the part that will make everything else make sense.
SMTP (Simple Mail Transfer Protocol) is the decades-old text protocol that literally moves an email from one server to another. Conceptually:
Your app ──SMTP──▶ Sending mail server ──SMTP──▶ Recipient's mail server ──▶ Inbox
(Gmail, Resend, (Gmail, Outlook,
SES, Mailgun...) Yahoo, corporate
mail server...)
A raw SMTP conversation looks roughly like this (simplified):
HELO myserver.com
MAIL FROM: <you@yourapp.com>
RCPT TO: <user@gmail.com>
DATA
Subject: Welcome!
From: you@yourapp.com
To: user@gmail.com
Hello, thanks for signing up!
.
QUIT
Nodemailer (the most common Node.js email library) builds this conversation for you — you never write raw SMTP, but understanding that it's happening demystifies a lot of "why is my email in spam" debugging later.
This is a distinction that confuses a lot of people because the words get used loosely. Here's the real breakdown:
1. Raw SMTP relay You (or a library like Nodemailer) speak SMTP directly to a mail server that will relay your message onward. Gmail's SMTP server, your own mail server, or a provider's SMTP endpoint (most providers offer one) all work this way.
Pros: universal, works with any language/library, no HTTP API needed.
Cons: slower (persistent connection, protocol overhead), harder to get detailed delivery events (opens, bounces, complaints) without extra webhook setup, connection/auth issues are common in serverless environments.
2. Transactional email HTTP APIs Instead of speaking SMTP, you make a normal HTTPS POST request to a provider's REST API (Resend, SendGrid, Mailgun, Postmark all offer this). The provider handles the SMTP conversation internally, on infrastructure with pre-warmed IP reputation.
Pros: faster, easier to work with in serverless functions, richer APIs (templates, attachments, tracking, webhooks for bounces/opens/clicks), better observability dashboards.
Cons: vendor lock-in to their SDK/response format (usually minor), you're paying for infrastructure you didn't have to build.
3. "Nodemailer with Gmail" (the tutorial favorite, and a trap) Nodemailer using your personal or Workspace Gmail account as an SMTP relay. This works for prototypes but:
Gmail enforces low sending limits (roughly 500/day for regular accounts, ~2,000/day for Workspace) and will throttle or lock accounts that look automated.
Google requires an "App Password" or OAuth2, not your real password, since 2022.
It has no dedicated deliverability infrastructure — you inherit Gmail's general reputation, and Gmail actively watches for exactly this kind of usage pattern (personal accounts sending programmatic bulk mail) and will suspend accounts.
Verdict: fine for local development and personal side projects that will never have more than a handful of users. Not appropriate for anything you plan to ship to real users.
Providers like Brevo or Mailchimp also do transactional email, but their core product is bulk marketing campaigns (newsletters to a list) — priced per contact rather than per email. Don't confuse "I need to email 1 user when they submit a form" (transactional) with "I need to email 10,000 subscribers about a new blog post" (bulk/marketing) — they're billed and architected differently, though the same provider can often do both.
Short answer: yes, generously enough for development and small production apps, but not indefinitely at scale, and free tiers vary a lot by provider and change often.
Here's the accurate, currently-verified picture (checked July 2026 — always re-check the provider's own pricing page before committing, since these numbers move):
| Provider | Free tier (verified July 2026) | Notes |
|---|---|---|
| Resend | 3,000 emails/month, capped at 100/day, one domain | The Free plan gives 3,000 emails a month, capped at 100 a day, on one domain. Considered permanent (not a trial) as of writing. |
| Brevo | 300 emails/day (~9,000/month) forever | Brevo allows sending 300 emails per day, roughly 9,000 per month, for free, indefinitely. No credit card required. |
| Amazon SES | 3,000 message charges/month for the first 12 months, then pay-as-you-go | Amazon SES offers up to 3,000 message charges per month for 12 months; after that, the cost is $0.10 per 1,000 emails — very cheap but not free forever, and requires more setup. |
| Mailgun | Trial only (roughly 5,000 emails for 1 month), no permanent free tier | Mailgun offers a trial account with 5,000 free emails for one month; after that it's paid. |
| SendGrid | Historically 100/day forever, but this changed — sources disagree | Multiple sources report SendGrid's permanent free plan was retired on May 27, 2025, and new direct signups now get a 60-day trial instead of an indefinite free tier. Treat "SendGrid has a free tier" with caution and verify on their pricing page before relying on it. |
| Nodemailer + Gmail | Free, but capped at ~500/day (personal) or ~2,000/day (Workspace), and not meant for this use case | Fine for dev only, as discussed above. |
When does "free" stop being enough?
The moment you have more than a trickle of real users doing verification emails, password resets, and order confirmations — a small SaaS with a few hundred active users can burn through 3,000–9,000 emails/month faster than you'd expect once you count every transactional trigger (not just registrations).
The moment you need daily send limits higher than a free tier's per-day cap, even if your monthly total is under the free ceiling (Resend's 100/day cap is a common surprise here).
The moment you need dedicated IPs, advanced analytics, higher support tiers, or multiple sending domains — those are gated behind paid plans everywhere.
Practical recommendation for a MERN side project going toward production: start on Resend's or Brevo's free tier during development and soft-launch, and budget roughly $15–$35/month once you're doing real transactional volume (registrations + password resets + notifications) for a small-to-medium app. This is comparable to Resend's Pro tier at $20/month for 50,000 emails or Brevo's paid tiers starting around similar territory.
| Provider | Best for | Free tier | Entry paid tier | Pros | Cons |
|---|---|---|---|---|---|
| Resend | Modern Node/React stacks, best DX | 3,000/mo (100/day) | $20/month for 50,000 emails (Pro) | Clean API, official React Email templates (write templates as JSX), great docs, modern dashboard | Newer company, smaller ecosystem than SendGrid/Mailgun, daily cap on free tier |
| Amazon SES | High volume, teams comfortable with AWS | 3,000/mo for 12 months | $0.10 per 1,000 emails | By far the cheapest at scale, integrates natively if you're already on AWS | Requires real engineering effort — no polished dashboard, you build your own analytics/deliverability monitoring; requires "production access" request to lift sandbox limits |
| SendGrid (Twilio) | Teams needing both transactional + marketing in one place, enterprises already on Twilio | Trial only (60 days) as of 2025+ | $19.95/month for 50,000 emails (Essentials) | Extremely mature, huge documentation/integration ecosystem, industry standard for years | Support quality has reportedly declined since the Twilio acquisition; free tier no longer permanent; steep price jump from Essentials to Pro |
| Mailgun | Developers needing advanced inbound email parsing/routing | Trial only (~1 month) | Around $35/month for 50,000 emails | Best-in-class inbound email parsing and routing rules, real support engineers | No permanent free tier, pricier than Resend/SES at comparable volume |
| Brevo | Non-technical teams wanting marketing + transactional in one dashboard | 300 emails/day forever, no credit card required | $9/month for 5,000 emails (Starter) | Most generous permanent free tier, all-in-one (email, SMS, CRM), non-developers can use the drag-and-drop editor | Developer experience is less polished than Resend/Mailgun; pricing structure changes fairly often |
| Nodemailer + Gmail | Local development, personal projects only | Free (with Gmail's normal limits) | N/A | Zero setup cost, works immediately | Not built for this, low limits, real risk of account suspension, no deliverability infrastructure — do not use in production |
Given you're building email verification and new-post notifications for a blog:
Resend is a strong default: React Email lets you write your verification/notification templates as JSX components (which fits naturally into a MERN dev's mental model), the API is a single HTTP call, and the free tier easily covers early-stage traffic.
If you specifically want the most generous permanent free tier and don't mind a slightly less polished API, Brevo is the alternative worth considering.
Avoid Nodemailer+Gmail beyond your local .env.development testing.
Never hardcode API keys. Ever. Not "just for now," not "I'll remove it before I commit."
# server/.env (never commit this file)
EMAIL_PROVIDER=resend
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxx
EMAIL_FROM="Your Blog <noreply@yourdomain.com>"
CLIENT_URL=https://yourdomain.com
JWT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# server/.gitignore
.env
.env.*
!.env.example
Always commit a .env.example with placeholder values so teammates (or future you) know what variables are needed, without leaking the real values:
# server/.env.example
EMAIL_PROVIDER=resend
RESEND_API_KEY=your_resend_api_key_here
EMAIL_FROM="Your App Name <noreply@yourdomain.com>"
CLIENT_URL=http://localhost:3000
Load them at the very top of your entry file:
// server/index.js
require('dotenv').config();
emailService.js abstractionStructure your service so the rest of your app never knows which provider you're using — this makes switching providers later (a real possibility, as the pricing tables above show) a one-file change.
// server/services/emailService.js
const { Resend } = require('resend');
const resend = new Resend(process.env.RESEND_API_KEY);
async function sendEmail({ to, subject, html, text }) {
try {
const result = await resend.emails.send({
from: process.env.EMAIL_FROM,
to,
subject,
html,
text, // always include a plain-text fallback
});
return result;
} catch (err) {
console.error('Email send failed:', err);
throw err; // let the caller decide how to handle it
}
}
async function sendVerificationEmail(user, token) {
const verifyUrl = `${process.env.CLIENT_URL}/verify/${token}`;
return sendEmail({
to: user.email,
subject: 'Verify your account',
html: verificationEmailTemplate({ name: user.name, verifyUrl }),
text: `Verify your account: ${verifyUrl}`,
});
}
module.exports = { sendEmail, sendVerificationEmail /*, ...other named senders */ };
Every specific email type (verification, password reset, new-post notification, order confirmation) gets its own named function that calls the generic sendEmail. This keeps your controllers readable:
// in authController.js
await emailService.sendVerificationEmail(user, token);
not
// avoid scattering raw provider calls through your controllers
await resend.emails.send({ ... 20 lines of inline HTML ... });
Store keys only in environment variables, injected by your hosting platform (Render, Railway, Vercel, Heroku, etc.), never in source.
Use different API keys for development and production so a leaked dev key can't touch your production sending reputation.
Scope keys to the minimum permission the provider allows (many providers let you create "send-only" keys with no access to your account settings).
Rotate a key immediately if it's ever exposed (committed by accident, logged, pasted into a chat, etc.) — providers let you revoke and regenerate instantly.
Never send API keys to the frontend. All email-sending must happen server-side. The frontend only ever calls your API, never the email provider directly.
Each of these follows the same shape from Part 0, so I'll show the parts that differ.
Backend flow:
Register → create user (isVerified: false) → generate token → save token+expiry on user
→ send verification email → respond 201 "check your email"
User clicks link → GET /auth/verify/:token → find user by token, check expiry
→ set isVerified: true, clear token → redirect to "verified!" page
// server/models/User.js (relevant fields)
const userSchema = new mongoose.Schema({
name: String,
email: { type: String, unique: true, required: true },
password: { type: String, required: true },
isVerified: { type: Boolean, default: false },
verificationToken: String,
verificationExpires: Date,
});
// server/controllers/authController.js
const crypto = require('crypto');
const bcrypt = require('bcrypt');
exports.register = async (req, res) => {
const { name, email, password } = req.body;
const hashedPassword = await bcrypt.hash(password, 12);
const rawToken = crypto.randomBytes(32).toString('hex');
const hashedToken = crypto.createHash('sha256').update(rawToken).digest('hex');
const user = await User.create({
name,
email,
password: hashedPassword,
verificationToken: hashedToken,
verificationExpires: Date.now() + 24 * 60 * 60 * 1000, // 24 hours
});
await emailService.sendVerificationEmail(user, rawToken); // send the RAW token, store the HASH
res.status(201).json({ message: 'Registered. Please check your email to verify your account.' });
};
exports.verifyEmail = async (req, res) => {
const hashedToken = crypto.createHash('sha256').update(req.params.token).digest('hex');
const user = await User.findOne({
verificationToken: hashedToken,
verificationExpires: { $gt: Date.now() },
});
if (!user) return res.status(400).json({ message: 'Invalid or expired verification link' });
user.isVerified = true;
user.verificationToken = undefined;
user.verificationExpires = undefined;
await user.save();
res.json({ message: 'Email verified! You can now log in.' });
};
Why hash the token before storing it? Same reason you hash passwords: if your database is ever leaked, an attacker with a raw verification token could log the user in (or reset their password, depending on your flow) without ever needing their password. Hashing the stored token means the leaked DB value is useless without the original raw token, which only ever went out in the emailed link.
Login must check isVerified:
exports.login = async (req, res) => {
const user = await User.findOne({ email: req.body.email });
if (!user) return res.status(401).json({ message: 'Invalid credentials' });
if (!user.isVerified) {
return res.status(403).json({ message: 'Please verify your email before logging in.' });
}
// ... check password, issue JWT, etc.
};
Nearly identical shape to verification, with two important differences:
The reset token should have a much shorter expiry — 15–60 minutes, not 24 hours, since this is a more sensitive action.
After a successful reset, invalidate all existing sessions/JWTs for that user if your app tracks them, since a password reset is often a sign of a compromised account.
exports.requestPasswordReset = async (req, res) => {
const user = await User.findOne({ email: req.body.email });
// Always respond the same way whether or not the user exists —
// this prevents attackers from using this endpoint to discover which emails are registered.
if (user) {
const rawToken = crypto.randomBytes(32).toString('hex');
user.resetToken = crypto.createHash('sha256').update(rawToken).digest('hex');
user.resetExpires = Date.now() + 30 * 60 * 1000; // 30 minutes
await user.save();
await emailService.sendPasswordResetEmail(user, rawToken);
}
res.json({ message: 'If that email exists, a reset link has been sent.' });
};
That "respond the same way regardless" pattern is a real security practice — it prevents user enumeration (an attacker probing your reset endpoint to find out which emails have accounts).
This is a fan-out pattern — one event, many recipients — which is architecturally different from the 1-to-1 emails above.
// server/controllers/postController.js
exports.publishPost = async (req, res) => {
const post = await Post.create({ ...req.body, published: true });
// Don't await this inline in the request/response cycle for large lists —
// queue it (see Part 9) or at minimum run it without blocking the response.
notifySubscribers(post).catch(err => console.error('Notify subscribers failed:', err));
res.status(201).json(post);
};
async function notifySubscribers(post) {
const subscribers = await Subscription.find({ active: true }).select('email -_id');
const batchSize = 50; // most providers rate-limit; batch to avoid hammering the API
for (let i = 0; i < subscribers.length; i += batchSize) {
const batch = subscribers.slice(i, i + batchSize);
await Promise.allSettled(
batch.map(sub => emailService.sendNewPostNotification(sub.email, post))
);
}
}
Key points specific to fan-out email:
Never put every subscriber's address in one to: field — that exposes everyone's email to everyone else. Send individually, or use the provider's batch/BCC-safe bulk endpoint if it exists.
Always include an unsubscribe link — most providers require this for deliverability compliance (and it's legally required in many jurisdictions under CAN-SPAM/GDPR/CASL).
Store an active/unsubscribed flag on the Subscription model rather than deleting records, so you have an audit trail.
Same 1-to-1 shape as verification, but usually must never fail silently, since it's tied to money changing hands.
exports.createOrder = async (req, res) => {
const order = await Order.create({ ...req.body, status: 'confirmed' });
try {
await emailService.sendOrderConfirmation(order);
} catch (err) {
// Log loudly and consider a retry queue — a customer who paid and got no
// confirmation email will contact support, so this needs monitoring, not
// a silent catch.
console.error(`CRITICAL: order confirmation email failed for order ${order._id}`, err);
}
res.status(201).json(order);
};
The simplest case — usually just notifying you (the site owner), sometimes with an auto-reply to the submitter too. This is what your uploaded transcript already covers in ContactSubmission.jsx / subscriptionController.js — the same principles above apply directly.
Email HTML is stuck in roughly 2003. Every mail client (Outlook desktop especially) uses its own rendering engine, many of them stripping modern CSS. Professional practice:
Use table-based layouts, not flexbox/grid. <table> with role="presentation" is still the most reliable cross-client layout method.
Inline all CSS. Many clients strip <style> blocks in the <head>. Use a tool to inline styles automatically rather than hand-inlining (see below).
Keep width around 600px. This is the de facto standard that renders well on both desktop and mobile mail clients.
Always include a plain-text version alongside the HTML (the text field in the code above) — this both helps deliverability (spam filters like seeing both) and supports plain-text-only clients.
Test across clients before shipping — see Part 10.
If you're using Resend (or really any provider — you can render to a static HTML string and send it anywhere), React Email lets you write your templates as React components with a constrained set of email-safe primitives, and it compiles them to the table-based, inlined-CSS HTML that actually works in Outlook:
// server/emails/VerificationEmail.jsx
import { Html, Body, Container, Heading, Text, Button } from '@react-email/components';
export default function VerificationEmail({ name, verifyUrl }) {
return (
<Html>
<Body style={{ fontFamily: 'sans-serif', backgroundColor: '#f6f6f6' }}>
<Container style={{ backgroundColor: '#ffffff', padding: '24px', borderRadius: '8px' }}>
<Heading>Hi {name}, verify your email</Heading>
<Text>Click the button below to verify your account. This link expires in 24 hours.</Text>
<Button href={verifyUrl} style={{ background: '#000', color: '#fff', padding: '12px 20px' }}>
Verify Email
</Button>
</Container>
</Body>
</Html>
);
}
const { render } = require('@react-email/render');
const VerificationEmail = require('./emails/VerificationEmail').default;
const html = await render(VerificationEmail({ name: user.name, verifyUrl }));
This gives you component reuse (a shared <EmailLayout>, <EmailButton>, etc.) exactly like your app's UI components — a natural fit for a MERN developer.
If you'd rather not add a template engine at all, a plain template-literal function returning an HTML string works fine for a small number of email types — just keep tables/inline-styles in mind either way.
This is the part that determines whether your emails land in the inbox or the spam folder, and it's independent of which provider or code you use.
If you send noreply@yourdomain.com without proving to the world that you're allowed to send as yourdomain.com, receiving mail servers have no way to trust that the email isn't spoofed — because historically, anyone could put any "From" address they wanted (email was designed in an era with no adversarial actors). Modern deliverability is built almost entirely on proving domain ownership through DNS.
1. SPF (Sender Policy Framework) A DNS TXT record on your domain that lists which mail servers are allowed to send email as your domain.
yourdomain.com. TXT "v=spf1 include:_spf.resend.com ~all"
This says: "Only servers listed by Resend (and anyone else I include) may send as yourdomain.com; anything else, treat with suspicion (~all = soft-fail)."
2. DKIM (DomainKeys Identified Mail) A cryptographic signature added to every outgoing email, verifiable via a public key published in your DNS. This proves the email's content wasn't altered in transit and really did originate from a server you authorized.
resend._domainkey.yourdomain.com. TXT "v=DKIM1; k=rsa; p=MIGfMA0GCSq..."
Your email provider generates this key pair and gives you the exact TXT record to add — you don't create it by hand.
3. DMARC (Domain-based Message Authentication, Reporting & Conformance) A policy record that tells receiving servers what to do if SPF or DKIM checks fail, and where to send reports about it.
_dmarc.yourdomain.com. TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@yourdomain.com"
p=quarantine means "send failing mail to spam"; p=reject means "refuse it outright" (use only once you're confident SPF/DKIM are solid); p=none is a monitoring-only mode good for your first week while you watch the reports.
Add your domain in the provider's dashboard (Resend, SES, Brevo, etc. all have a "Domains" section).
The provider gives you exact TXT/CNAME records to add.
Add them at your DNS host (Namecheap, Cloudflare, GoDaddy, Route 53...).
Wait for propagation (minutes to a few hours) and click "verify" in the provider dashboard.
Once verified, send from that domain — never from a free Gmail/Yahoo address in production, since those domains' SPF/DKIM don't authorize your server.
Sending from an unverified domain or from a generic address like @gmail.com in production.
No plain-text version of the email (spam filters weight HTML-only emails as suspicious).
Spammy trigger words in subject lines ("FREE", "ACT NOW", excessive exclamation marks/caps).
Too many links, or all-image emails with no text — a classic spam pattern filters watch for.
Sudden volume spikes on a new domain/IP with no sending history — "reputation warm-up" is a real thing; ramp volume gradually on a brand-new domain rather than blasting 10,000 emails on day one.
High bounce/complaint rates left unmanaged — if you keep emailing addresses that bounce or that mark you as spam, your entire domain's reputation degrades, hurting deliverability for all your emails, not just the bad ones. Providers expose bounce/complaint webhooks specifically so you can prune your list.
Missing List-Unsubscribe header on bulk sends (most providers add this automatically for you, but verify it if you build low-level).
Mismatched "From" and reply-to domains, or a "From" name that doesn't match your actual sending domain.
/register or /contact endpoint in a loop and either exhaust your email quota or use your server as a spam relay against a third party's address.const rateLimit = require('express-rate-limit');
const emailLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 requests per IP per window
message: 'Too many requests, please try again later.',
});
router.post('/contact', emailLimiter, contactController.submitContact);
router.post('/auth/request-password-reset', emailLimiter, authController.requestPasswordReset);
Validate and sanitize every input server-side (express-validator, as shown above) — never trust that the frontend's validation ran.
Never expose whether an email is registered through response differences (the password-reset pattern from Part 6.2).
Store only hashed tokens, never raw tokens, in the database.
Set short, sane expirations on all tokens (verification: hours; password reset: minutes).
Never log full email bodies or API keys in your server logs — log event metadata (recipient, template name, success/failure) instead.
Handle provider errors gracefully — a try/catch around every send, with monitoring/alerting on failures for anything transactional (order confirmations, password resets) rather than a silent console.log.
Separate dev/staging/prod API keys and sending domains so a bug in staging can't spam real users or burn your production sending reputation.
CORS-lock your API so only your actual frontend origin can call these endpoints.
Don't send real emails to real inboxes while iterating on templates. Use an email-sandbox tool like Mailtrap (a fake SMTP inbox that captures emails without delivering them, so you can inspect HTML/rendering safely) or your provider's own test/sandbox mode if it has one.
Preview your templates directly. React Email ships a local preview server (email dev) that renders your .jsx templates in-browser without sending anything.
Test across real clients, not just your browser preview — tools like Litmus or Email on Acme (or simply sending test emails to a Gmail account, an Outlook.com account, and an actual Outlook desktop client if you have access) catch rendering bugs no simulator does, since Outlook desktop in particular uses Microsoft Word's rendering engine internally and is notoriously inconsistent.
Use a .env.development with lower rate limits and a test API key so accidental loops during development can't run up a bill or burn your sending reputation.
Monitor your provider's dashboard for bounce rate, complaint rate, and delivery rate — these are the leading indicators of a deliverability problem before it becomes a full-blown "all our emails are going to spam" crisis.
Subscribe to webhook events (bounced, complained, delivered) and react to them — e.g., automatically mark a subscriber inactive after a hard bounce.
Log every send attempt with a status (queued/sent/failed) tied to the triggering event (which user, which order, which post) so support can answer "did the customer actually get this email?" without guessing.
Set up alerting (even a simple Slack webhook or email-to-yourself) for spikes in failure rate.
A few practices that separate a "works in the demo" implementation from a production-grade one:
For anything beyond a single verification email, sending inline in the request handler means your user's browser sits waiting on a network call to a third-party service before they get a response. Two common fixes, in increasing order of robustness:
Fire-and-forget with logging (fine for small apps): call emailService.send(...) without await-blocking the response, but always .catch() it so failures are logged, not swallowed silently.
A real job queue (the professional standard at any real scale): push a "send verification email to user X" job onto a queue (BullMQ + Redis is the standard Node.js choice) and have a separate worker process consume it. This gives you automatic retries with backoff, visibility into failed jobs, and it means a slow or down email provider never affects your API's response time.
// enqueue instead of sending directly
await emailQueue.add('sendVerification', { userId: user._id, token: rawToken });
// separate worker process
emailQueue.process('sendVerification', async (job) => {
const user = await User.findById(job.data.userId);
await emailService.sendVerificationEmail(user, job.data.token);
});
If a payment provider or your own retry logic could call your "send order confirmation" path twice for the same order, guard against sending the email twice — check a confirmationEmailSentAt field on the order before sending, and set it right after a successful send.
Treat email templates like UI components: keep them in source control, review changes in PRs, and where possible snapshot-test the rendered HTML output so a template refactor can't silently break a live email.
Your controllers should call emailService.sendVerificationEmail(user, token), never touch the provider SDK directly. This is the abstraction from Part 5.2, and it's what lets you migrate from Resend to SES to Brevo (as your pricing tables above might eventually push you to do) by editing one file instead of every controller.
When you deploy your MERN app with this email system to production:
Set real environment variables on your hosting platform (Render/Railway/Heroku/Vercel dashboard, or a .env injected via your platform's secrets manager) — never bake them into your Docker image or commit them.
Verify your production sending domain's DNS records (SPF/DKIM/DMARC) before going live — do this a few days ahead of launch since DNS propagation and reputation warm-up both take time.
Use a production-tier API key, separate from the one you used in development.
Confirm your CLIENT_URL environment variable points to your real production domain — a shockingly common bug is verification/reset links pointing to localhost:3000 in production because a dev .env value leaked into the deployed config.
Set up webhook endpoints for bounce/complaint events if your provider supports them, and make sure that endpoint is publicly reachable from your production URL (not just localhost).
Load-test your rate limiter thresholds against your expected real traffic so legitimate bursts (e.g., a marketing push) don't get throttled by mistake.
Have a monitoring/alerting hook in place before, not after, your first real traffic spike.
Email verification:
[ ] Add isVerified, verificationToken (hashed), verificationExpires to User model
[ ] Registration creates user as unverified, generates token, sends email
[ ] /auth/verify/:token route validates token + expiry, flips isVerified
[ ] Login blocks unverified users with a clear message
[ ] Frontend: registration success message + a verification-landing page/component
[ ] Rate-limit registration and resend-verification endpoints
New-post subscriber notifications:
[ ] Subscription model stores real subscriber emails with an active flag
[ ] Subscription controller fixed to stop using a hardcoded test address
[ ] publishPost fans out to all active subscribers in batches, without blocking the response
[ ] Every notification email includes an unsubscribe link/mechanism
[ ] Domain verified (SPF/DKIM/DMARC) before sending to real subscriber lists
[ ] Bounce/complaint webhooks wired up to auto-deactivate bad addresses
You now have the complete mental model — the protocol underneath, the provider landscape and its real current pricing, the security patterns, and the deployment steps — to implement this in your mern project without needing to follow a tutorial line-by-line.