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.