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.