# Send email from DaloyJS with Mailgun

[Mailgun](https://www.mailgun.com) (now Sinch Mailgun) offers high-volume transactional and bulk email, address validation, and routing. This guide uses the official [`mailgun.js`](https://github.com/mailgun/mailgun.js) SDK.

**Diagram: Send through the mailgun.js SDK**

Participants: Route handler, mg client, Mailgun API, Webhooks

1. **Route handler -> mg client** (request) - mg.messages.create(DOMAIN, { from, to, subject }) - client keyed with MAILGUN_API_KEY
2. **mg client -> Mailgun API** (request) - POST /v3/{domain}/messages - US api.mailgun.net or EU api.eu.mailgun.net (fixed per domain)
3. **Mailgun API -> Route handler** (response) - { id, message } - return { id } on success
4. **Mailgun API -> Webhooks** (async) - delivery, bounce, complaint events - verify HMAC-SHA256(timestamp + token) before trusting

The region is fixed per domain (US or EU), set url accordingly. On edge runtimes pass useFetch: true so the SDK uses native fetch. Webhook payloads are HMAC-signed, verify them before acting.

## 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.ts";

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

app.post(
  "/invites",
  {
    operationId: "sendInvite",
    request: {
      body: z.object({
        to: z.email(),
        inviter: z.string().min(1).max(80),
      }),
    },
    responses: {
      202: { description: "Sent", body: z.object({ id: z.string() }) },
    },
  },
  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](https://handlebarsjs.com/) 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: pass `useFetch: true` so 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](/docs/email/aws-ses), [Resend](/docs/email/resend), and the [email integrations overview](/docs/email).

---

Source: https://daloyjs.dev/docs/email/mailgun