Mail: SMTP (Mailhog locally, anything in prod)
Transactional email via the canonical
Mailercontract.
@nwire/mail-nodemailer wraps nodemailer, which speaks SMTP and is the de-facto Node mail library. Same handler code works against Mailhog, your provider's SMTP relay, or a self-hosted Postfix.
Install
pnpm add @nwire/mail @nwire/mail-nodemailerLocal development with Mailhog
The repo's docker-compose.yml ships Mailhog pre-configured. Start it:
nwire infra upThat gives you:
- SMTP listener on
localhost:1025(no auth) - Web UI at
http://localhost:8025showing every captured message
Wire it into your app:
import { defineApp } from "@nwire/forge"
import { mailPlugin } from "@nwire/mail"
import { smtpMailer } from "@nwire/mail-nodemailer"
export const app = defineApp("my-app", {
modules: [/* ... */],
plugins: [mailPlugin({
mailer: smtpMailer({ host: "localhost", port: 1025 }),
defaultFrom: '"My App" <noreply@my.app>',
})],
})Send from a handler
import { defineAction } from "@nwire/forge"
import type { Mailer } from "@nwire/mail"
import { z } from "zod"
export const sendWelcome = defineAction("sendWelcome", {
input: z.object({ email: z.string().email(), name: z.string() }),
handler: async ({ input, resolve }) => {
const mailer = resolve<Mailer>("mailer")
await mailer.send({
to: input.email,
subject: "Welcome to My App",
html: `<p>Hi ${input.name}!</p>`,
})
return { sent: true }
},
})Open http://localhost:8025 and you'll see the message captured.
Switch to a real SMTP relay in production
Same code; change the connection params:
smtpMailer({
host: process.env.SMTP_HOST!,
port: Number(process.env.SMTP_PORT),
secure: true, // port 465 with TLS
auth: {
user: process.env.SMTP_USER!,
pass: process.env.SMTP_PASS!,
},
})Works with Gmail, Office365, AWS SES SMTP interface, SendGrid SMTP interface, Postmark SMTP interface, Mailgun SMTP, and self-hosted Postfix.
Testing without a real SMTP server
Use the in-memory mailer from @nwire/mail:
import { mailPlugin, InMemoryMailer } from "@nwire/mail"
const mailer = new InMemoryMailer()
const app = createApp({
modules: [...],
plugins: [mailPlugin({ mailer })],
})
// run your flow
await app.runtime.dispatch(welcomeUser, { userId: "u1" })
// assert against captured messages
expect(mailer.sent).toHaveLength(1)
expect(mailer.sent[0].subject).toBe("Welcome to My App")Default From
defaultFrom is applied to every message that doesn't set its own from. Set it once at plugin config — every handler stays clean of the "who do we send as" detail.
Health checks
The adapter calls transporter.verify() on every readiness probe. Bad credentials or unreachable SMTP host fails /readyz quickly.
Why no @nwire/mail-ses etc. yet
SES (and SendGrid, Resend, Postmark) all accept SMTP. For ~90% of apps, the SMTP transporter you already configured for Mailhog works against production providers with two env var changes. Native API adapters (skipping SMTP) will arrive when there's a reason — e.g., wanting SES batched sending, or SendGrid's templating.