Send emails with Express
npm install mailstein
import express from "express";
import { Mailstein } from "mailstein";
const app = express();
app.use(express.json());
const mailstein = new Mailstein(process.env.MAILSTEIN_API_KEY);
app.post("/signup", async (req, res) => {
const { email } = req.body;
const { data, error } = await mailstein.emails.send({
from: "hello@yourdomain.com",
to: email,
subject: "Welcome",
html: "<p>Thanks for signing up.</p>",
});
if (error) {
console.error(error);
return res.status(502).json({ error: "Could not send" });
}
res.json({ id: data.emailId });
});
app.listen(3000);
Receiving webhooks
The signature is computed over the raw body. express.json() parses and
discards it, and re-serialising produces different bytes, so verification
fails. Mount the raw parser on the webhook route only:
import { createHmac, timingSafeEqual } from "crypto";
app.post(
"/hooks/mailstein",
express.raw({ type: "application/json" }),
(req, res) => {
const raw = req.body.toString("utf8");
const timestamp = req.get("x-mailstein-timestamp");
const signature = req.get("x-mailstein-signature");
if (Math.abs(Date.now() - Number(timestamp)) > 5 * 60 * 1000) {
return res.status(400).send("stale");
}
const expected =
"v1=" +
createHmac("sha256", process.env.MAILSTEIN_WEBHOOK_SECRET)
.update(`${timestamp}.${raw}`)
.digest("hex");
const a = Buffer.from(signature ?? "");
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.status(400).send("bad signature");
}
// Answer immediately, work afterwards: a handler that does its processing
// inline eventually times out, and then the event arrives again.
res.sendStatus(200);
void handle(JSON.parse(raw));
},
);
Order matters — the raw route has to be registered before any global
express.json(), or the JSON parser gets there first.
Something here wrong or missing? It is generated from the running API —
tell us and we will fix the source.