Email
@nwire/mail is the Mailer contract — send, verify. The @nwire/mail-nodemailer adapter wraps nodemailer, which speaks SMTP and works with any provider that does (SendGrid, Postmark, AWS SES, self-hosted Postfix, Mailhog for local dev).
Handler code stays the same. Switch the SMTP credentials at the wire; the handler never knows.
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 { createApp } from "@nwire/app"
import { mailPlugin } from "@nwire/mail"
import { smtpMailer } from "@nwire/mail-nodemailer"
export const app = createApp({
appName: "my-app",
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"
import { forgePlugins } from "@nwire/forge"
const mailer = new InMemoryMailer()
const app = createApp({
appName: "shop",
handlers: [welcomeUser],
plugins: [...forgePlugins()],
})
.with(mailPlugin({ mailer }))
// run your flow
await app.runtime.execute(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 /ready 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.