DEV Community

Cover image for How to Send Email from Express
sohom das
sohom das

Posted on

How to Send Email from Express

The fastest path most tutorials show is Nodemailer connected to Gmail's SMTP server, and it does work for a quick test. It's not what I'd actually ship to production, though, and it's worth understanding exactly why before you build a real feature on top of it. Here's both versions — the quick one, and the one I'd actually use, sending through Notify instead of Gmail SMTP.

The Quick Version: Nodemailer + Gmail

This is the pattern you'll find in most tutorials:

npm install nodemailer
Enter fullscreen mode Exit fullscreen mode
const nodemailer = require("nodemailer");

const transporter = nodemailer.createTransport({
  service: "gmail",
  auth: {
    user: process.env.EMAIL_USER,
    pass: process.env.EMAIL_PASS, // an App Password, not your regular Gmail password
  },
});

app.post("/send-email", async (req, res) => {
  try {
    const { to, subject, text } = req.body;
    const info = await transporter.sendMail({
      from: process.env.EMAIL_USER,
      to,
      subject,
      text,
    });
    res.json({ message: "Email sent", id: info.messageId });
  } catch (error) {
    res.status(500).json({ message: "Failed to send email" });
  }
});
Enter fullscreen mode Exit fullscreen mode

It works, and for a personal project or a one-off script, it's genuinely fine. Where it breaks down is anything you'd call "production."

Why This Common Pattern Doesn't Hold Up in Production

Gmail has hard daily sending limits. As of 2026, a free personal Gmail account tops out at 500 outgoing messages a day, and a Google Workspace account at 2,000 — cross that and Google blocks further sends for up to 24 hours. For a real app sending password resets and notifications, you can hit that ceiling faster than you'd expect once you have real users.

You're sending as your own inbox, not your app. Gmail's own limits aside, mail authenticated as a personal Gmail address rather than your app's own verified domain doesn't carry the same deliverability trust — receiving servers increasingly expect transactional mail to come from a domain that's properly authenticated with SPF, DKIM, and DMARC, which a personal Gmail account isn't set up to do on your behalf.

App Passwords are a real credential to protect. Storing a Gmail App Password in your app's environment variables means a leak of that credential compromises access tied to a real inbox, not a scoped, revocable API key built for exactly this purpose.

There's no visibility into what happened. Nodemailer tells you the send succeeded from SMTP's point of view, but you don't get delivery confirmation, bounce data, or open/click events without building that separately. When a user says "I never got the reset email," Gmail SMTP gives you nothing to check — you're guessing.

None of this means Nodemailer is a bad library — it's a solid SMTP client, and it'll work fine as a client for a proper transactional provider too, if that provider offers SMTP. The problem is specifically routing it through a personal Gmail account for automated, production email, which Gmail was never built to be the backend for.

The Production Version: Express + Notify

There's no package to install here — Notify has no SDK, just an HTTP API, so fetch (built into Node 18+) is all you need:

const express = require("express");
require("dotenv").config();

const app = express();
app.use(express.json());

app.post("/send-email", async (req, res) => {
  try {
    const { to, subject, message } = req.body;

    const response = await fetch("https://notify.cx/api/email/send", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "x-api-key": process.env.NOTIFY_API_KEY,
      },
      body: JSON.stringify({
        to,
        from: "noreply@your-verified-domain.com",
        subject,
        message,
      }),
    });

    if (!response.ok) {
      throw new Error(`Notify API responded with ${response.status}`);
    }

    res.json({ message: "Email sent" });
  } catch (error) {
    console.error(error);
    res.status(500).json({ message: "Failed to send email" });
  }
});

app.listen(3000, () => console.log("Server running on port 3000"));
Enter fullscreen mode Exit fullscreen mode

.env:

NOTIFY_API_KEY=your_api_key_here
Enter fullscreen mode Exit fullscreen mode

Before this works in production, you need a verified sending domain — add SPF, DKIM, and DMARC records, which takes up to 24–48 hours to propagate. While that's pending, Notify's sandbox lets you test the route immediately.

Adding Basic Validation and Rate Limiting

The route above works, but exposing a raw "send email to any address" endpoint from your Express app is asking for abuse — someone could use it to spam arbitrary recipients through your app's identity. A couple of things worth adding before this goes live:

const rateLimit = require("express-rate-limit");

const emailLimiter = rateLimit({
  windowMs: 60 * 1000, // 1 minute
  max: 5, // 5 requests per IP per minute
});

app.post("/send-email", emailLimiter, async (req, res) => {
  const { to, subject, message } = req.body;

  if (!to || !subject || !message) {
    return res.status(400).json({ message: "Missing required fields" });
  }

  // ...rest of the send logic
});
Enter fullscreen mode Exit fullscreen mode

This is generic Express practice, not something specific to Notify or any provider — but it matters more than the send call itself in terms of actually protecting a production endpoint. Whatever you're sending through — Notify, Nodemailer, or anything else — an unauthenticated, unlimited "send email" route is a liability regardless of which service is on the other end of it.

Testing the Route

Once your route is running, a quick curl confirms the whole path end to end:

curl -X POST http://localhost:3000/send-email \
  -H "Content-Type: application/json" \
  -d '{
    "to": "your-own-email@example.com",
    "subject": "Test from Express",
    "message": "<p>If this arrives, the route works.</p>"
  }'
Enter fullscreen mode Exit fullscreen mode

Checking delivery logs right after confirms not just that the request succeeded, but that Notify actually attempted delivery — a distinction that matters, since a 200 response from your own route only tells you the request reached Notify, not that the recipient's server accepted it.

Sending HTML Instead of Plain Text

Both Nodemailer and Notify accept HTML directly — just build the string and pass it in place of plain text:

body: JSON.stringify({
  to,
  from: "noreply@your-verified-domain.com",
  subject: "Welcome",
  message: "<h1>Welcome!</h1><p>Thanks for signing up.</p>",
})
Enter fullscreen mode Exit fullscreen mode

Reacting to Delivery Failures

This is the part that's genuinely hard to build from scratch with Gmail SMTP — knowing whether an email actually landed. With Notify, it's a single call:

await fetch("https://notify.cx/api/webhooks", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "x-api-key": process.env.NOTIFY_API_KEY,
  },
  body: JSON.stringify({
    webhookUrl: "https://yourapp.com/webhooks/email",
    subscribedEvents: ["Bounce", "Delivery"],
    domainId: "your-domain-id",
  }),
});
Enter fullscreen mode Exit fullscreen mode

If you want the full request and response details before wiring this into a real route, the docs cover it in a few minutes.

Comparing the Two Approaches

Nodemailer + Gmail Express + Notify
Package to install nodemailer None — plain fetch
Daily sending limit 500/day (free), 2,000/day (Workspace) 1,000/mo free, 10,000/mo on $10/month Pro
Sends from Your personal/Workspace inbox Your own verified domain
Credential type Gmail App Password (tied to a real inbox) Scoped, regenerable API key
Delivery visibility None built in Delivery logs + webhooks
Meant for production use Not really — a personal email feature Yes, by design

The gap in that last row is really the whole point. Gmail SMTP via Nodemailer is a perfectly good way to fire off an occasional email from a script you run yourself. It was never designed to be the sending backend for an application other people depend on, and the daily caps, credential model, and lack of visibility all reflect that. Swapping the transporter for an actual transactional API doesn't change much about how your Express route is structured — it's still a POST handler that builds a payload and sends it — but it changes what happens once that email leaves your server, which is the part that actually matters once real users are on the other end.

Frequently Asked Questions

How to send email from Express?

The quick way is Nodemailer connected to Gmail's SMTP server, but for anything beyond a personal script, a transactional email API like Notify is the better fit — no SDK required, just a fetch call to https://notify.cx/api/email/send with an API key, plus a verified sending domain instead of a personal Gmail account.

Why shouldn't I use Gmail SMTP for a production Express app?

Gmail caps free accounts at 500 outgoing messages a day (2,000 for Workspace), sends as a personal inbox rather than your app's own authenticated domain, and gives you no delivery or bounce visibility without building that yourself.

Do I need Nodemailer to send email from Notify?

No — Notify is a plain HTTP API, so a native fetch call in Node.js works without any additional package.

What is Notify?

Notify is a lightweight transactional email API for developers — one endpoint to send, domain verification, delivery logs, and webhooks, without templates or SMTP configuration.

Can I test sending email from Express before my domain is verified?

Yes — Notify's sandbox lets you send test emails immediately, so you can confirm your Express route works correctly while DNS verification is still in progress.

How do I know if an email sent from my Express app actually got delivered?

Register a webhook subscribed to Delivery and Bounce events, and Notify will notify your app automatically instead of you checking manually or waiting for a user to report a problem.

Should I add rate limiting to my Express email route?

Yes — an unauthenticated or unlimited "send email" endpoint can be abused to send arbitrary mail through your app's identity, regardless of which provider is behind it. A basic per-IP rate limit (via express-rate-limit or similar) is worth adding before any email-sending route goes live.

Top comments (0)