Express.js email API: send transactional email from an Express app

Updated

There is no Express-specific Inboxili package. Use the official Node SDK (@inboxili/node) from your routes or services, as shown below.

Express does not need a special integration. You create one SDK client at startup, wrap it in a small service, and map its errors to the HTTP statuses your own API returns.

Install

npm install express @inboxili/node
export INBOXILI_API_KEY="ik_live_..."   # a key with the transactional:send scope

A service module

mailer.js:

import { Inboxili, InboxiliError, InboxiliConnectionError } from "@inboxili/node";

export function createMailer({ apiKey = process.env.INBOXILI_API_KEY, fromEmail = "hello@yourdomain.com", client } = {}) {
  const inboxili = client ?? new Inboxili({ apiKey });

  return {
    async send({ to, subject, htmlBody, textBody, templateData }) {
      return inboxili.emails.send({ to, fromEmail, fromName: "Acme", subject, htmlBody, textBody, templateData });
    },
  };
}

/** Turns SDK errors into a status code and a safe message for your own API's clients. */
export function toHttpError(err) {
  if (err instanceof InboxiliError) {
    if (err.status === 429) return { status: 503, message: "Email is busy, try again shortly." };
    if (err.status >= 400 && err.status < 500) return { status: 502, message: "Email could not be sent." }; // our configuration problem, not the caller's
    return { status: 502, message: "Email provider error." };
  }
  if (err instanceof InboxiliConnectionError) return { status: 504, message: "Email request timed out." };
  return { status: 500, message: "Unexpected error." };
}

Raw SDK messages stay in your logs. Callers of your API get a generic message, because sender_not_verified or unauthorized describe your configuration.

A route

app.js:

import express from "express";
import { createMailer, toHttpError } from "./mailer.js";

export function createApp({ mailer = createMailer() } = {}) {
  const app = express();
  app.use(express.json({ limit: "20kb" }));

  app.post("/api/invite", async (req, res) => {
    const { email, inviter } = req.body ?? {};
    if (typeof email !== "string" || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
      return res.status(400).json({ error: "A valid email is required." });
    }
    try {
      const { messageId } = await mailer.send({
        to: email,
        subject: "{{inviter}} invited you",
        htmlBody: "<p>{{inviter}} invited you to Acme. <a href=\"https://app.yourdomain.com/join\">Join</a></p>",
        templateData: { inviter: String(inviter ?? "A teammate").slice(0, 80).replace(/[<>&"']/g, "") },
      });
      res.status(202).json({ queued: true, messageId });
    } catch (err) {
      console.error("send failed", err.status, err.code, err.message);
      const { status, message } = toHttpError(err);
      res.status(status).json({ error: message });
    }
  });

  return app;
}

Run it with createApp().listen(3000).

Public endpoints need rate limiting

Any route that sends mail on request can be abused. Add per-IP limiting, for example with express-rate-limit, and a stricter limit per recipient. Each API key allows 120 requests per minute, so one abusive client can otherwise use your whole allowance.

Testing

Inject a fake mailer so tests never send:

import test from "node:test";
import assert from "node:assert/strict";
import { createApp } from "./app.js";

test("POST /api/invite sends and returns 202", async () => {
  const sent = [];
  const app = createApp({ mailer: { send: async (m) => (sent.push(m), { messageId: "m1" }) } });
  const server = app.listen(0);
  const { port } = server.address();

  const res = await fetch(`http://127.0.0.1:${port}/api/invite`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email: "ada@example.com", inviter: "Grace" }),
  });
  server.close();

  assert.equal(res.status, 202);
  assert.equal(sent[0].to, "ada@example.com");
});

test("rejects a bad address", async () => {
  const app = createApp({ mailer: { send: async () => assert.fail("should not send") } });
  const server = app.listen(0);
  const res = await fetch(`http://127.0.0.1:${server.address().port}/api/invite`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email: "nope" }),
  });
  server.close();
  assert.equal(res.status, 400);
});

Troubleshooting

| Symptom | Cause | Fix | |---|---|---| | req.body is undefined | express.json() not registered before the route | Add the middleware first. | | 401 from Inboxili in logs | INBOXILI_API_KEY not set in the process that runs Express | Set it in the service or container environment. | | 422 sender_not_verified | fromEmail is not on a verified domain | Verify the domain and add the sender. | | Requests hang | No timeout configured | The SDK defaults to 10 seconds; lower it with timeoutMs if your route needs to answer faster. | | Many 503s during a burst | Over 120 requests per minute | Queue sends with a worker and pace them. |

Workflows to build on this: contact form, OTP, notifications.

Frequently asked questions

Do I need middleware or a plugin for Express?
No. The SDK is a plain object you create once and call from any route, job or service.
Where should the API key live?
In an environment variable on the server, loaded at startup. Never send it to a browser or commit it.

Official SDKs

Build with Inboxili

Create a workspace, verify a domain, and make your first API call.

Related