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
- Add and verify your sending domain under Sending → Domains → Add new domain. Add the SPF/DKIM TXT records and the MX records Mailgun lists.
- Choose your region:
https://api.mailgun.net(US, default) orhttps://api.eu.mailgun.net(EU). The region is fixed per domain. - Create a Sending API key from API Security and store it as
MAILGUN_API_KEY.
2. Install
pnpm add mailgun.js form-dataform-data is the multipart implementation Mailgun expects on Node.
3. Environment variables
# .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.net4. Plugin
// 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
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:
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 nativefetchinstead ofrequest(which depends on Node's HTTP module):Whenconst mg = mailgun.client({ username: "api", key: process.env.MAILGUN_API_KEY!, url: process.env.MAILGUN_URL, useFetch: true, });useFetchis enabled, you can also drop theform-datadependency in favour of the globalFormData.
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.