Skip to content

Mail: SMTP (Mailhog locally, anything in prod)

Transactional email via the canonical Mailer contract.

@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

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 { 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

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"

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.

MIT licensed.