Skip to content

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

sh
pnpm add @nwire/mail @nwire/mail-nodemailer

Local development with Mailhog

The repo's docker-compose.yml ships Mailhog pre-configured. Start it:

sh
nwire infra up

That gives you:

  • SMTP listener on localhost:1025 (no auth)
  • Web UI at http://localhost:8025 showing every captured message

Wire it into your app:

ts
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

ts
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:

ts
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:

ts
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.

MIT licensed.