Search docs

Jump between documentation pages.

Send email from DaloyJS with Mailgun

Mailgun (now Sinch Mailgun) offers high-volume transactional and bulk email, address validation, and routing. This guide uses the official mailgun.js SDK.

1. Provision

  1. Add and verify your sending domain under Sending → Domains → Add new domain. Add the SPF/DKIM TXT records and the MX records Mailgun lists.
  2. Choose your region: https://api.mailgun.net (US, default) or https://api.eu.mailgun.net (EU). The region is fixed per domain.
  3. Create a Sending API key from API Security and store it as MAILGUN_API_KEY.

2. Install

ts
pnpm add mailgun.js form-data

form-data is the multipart implementation Mailgun expects on Node.

3. Environment variables

ts
# .env
MAILGUN_API_KEY=key-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
MAILGUN_DOMAIN=mg.acme.example.com
MAILGUN_FROM="Acme <no-reply@mg.acme.example.com>"
# Optional: use EU
# MAILGUN_URL=https://api.eu.mailgun.net

4. Plugin

ts
// src/plugins/mailgun.ts
import Mailgun from "mailgun.js";
import FormData from "form-data";
import type { App } from "@daloyjs/core";

const mailgun = new Mailgun(FormData);
const mg = mailgun.client({
  username: "api",
  key: process.env.MAILGUN_API_KEY!,
  url: process.env.MAILGUN_URL, // omit for the default US endpoint
});

const DOMAIN = process.env.MAILGUN_DOMAIN!;

export const mailgunPlugin = {
  name: "mailgun",
  register(app: App) {
    app.decorate("email", {
      async send({ to, subject, text, html }) {
        const res = await mg.messages.create(DOMAIN, {
          from: process.env.MAILGUN_FROM!,
          to: [to],
          subject,
          text,
          html,
        });
        return { id: res.id ?? "" };
      },
    });
  },
};

declare module "@daloyjs/core" {
  interface AppState {
    email: {
      send(msg: {
        to: string;
        subject: string;
        text?: string;
        html?: string;
      }): Promise<{ id: string }>;
    };
  }
}

5. Use it in a route

ts
import { z } from "zod";
import { App, secureHeaders, rateLimit } from "@daloyjs/core";
import { mailgunPlugin } from "./plugins/mailgun";

const app = new App();
app.use(secureHeaders());
app.use(rateLimit({ windowMs: 60_000, max: 10 }));
app.register(mailgunPlugin);

app.route({
  method: "POST",
  path: "/invites",
  operationId: "sendInvite",
  request: {
    body: z.object({
      to: z.string().email(),
      inviter: z.string().min(1).max(80),
    }),
  },
  responses: {
    202: { description: "Sent", body: z.object({ id: z.string() }) },
  },
  handler: async ({ body, state }) => {
    const { id } = await state.email.send({
      to: body.to,
      subject: `${body.inviter} invited you to Acme`,
      text: `${body.inviter} invited you. Join: https://acme.example.com/join`,
    });
    return { status: 202, body: { id } };
  },
});

Templates

Create a stored template in Sending → Templates with Handlebars syntax, then reference it by name and pass variables as a JSON string:

ts
await mg.messages.create(DOMAIN, {
  from: process.env.MAILGUN_FROM!,
  to: [to],
  subject: "Welcome",
  template: "welcome",
  "h:X-Mailgun-Variables": JSON.stringify({ name: "Devlin" }),
});

Runtimes

  • Node / Bun / AWS Lambda — works with the configuration above.
  • Cloudflare Workers / Vercel Edge — pass useFetch: trueso the SDK uses the platform's native fetch instead of request (which depends on Node's HTTP module):
    ts
    const mg = mailgun.client({
      username: "api",
      key: process.env.MAILGUN_API_KEY!,
      url: process.env.MAILGUN_URL,
      useFetch: true,
    });
    When useFetch is enabled, you can also drop the form-data dependency in favour of the global FormData.

Webhooks

Mailgun signs webhook payloads with HMAC-SHA256. When you accept delivery, bounce, or complaint events, verify signature.signature against timestamp + token using your webhook signing key before trusting the body.

See also AWS SES, Resend, and the email integrations overview.