Skip to main content

AuthSmtpChannel

WIP

Nodemailer-compatible SMTP relay. Works with Postmark, Mailgun, custom SMTP servers - anywhere you have credentials.

Install

bun add nodemailer

AuthSmtpChannel lazy-loads nodemailer - apps that don't use SMTP pay no cost.

Setup

import { AuthSmtpChannel } from '@gentleduck/auth/channels/smtp'

const channel = new AuthSmtpChannel({
  from: 'no-reply@example.com',
  host: 'smtp.example.com',
  port: 587,
  secure: false,        // upgrades to STARTTLS
  auth: {
    user: process.env.SMTP_USER!,
    pass: process.env.SMTP_PASS!,
  },
  id: 'smtp',           // shown in audit events
})

For port 465 (implicit TLS), set secure: true.

import { magicLink } from '@gentleduck/auth/providers/magic-link'

auth.providers.use(
  magicLink({
    channels: { email: channel },
    findIdentityByEmail: (email) => auth.identities.findByEmail(email),
    autoCreateIdentity: true,
  }),
)

Templating

The channel handles the SMTP delivery; rendering the message body is your responsibility. The default templates are minimal - for branded emails, subclass AuthSmtpChannel and override renderMessage():

class BrandedSmtpChannel extends AuthSmtpChannel {
  async renderMessage({ templateId, vars }) {
    if (templateId === 'magic-link') {
      return {
        subject: `Sign in to ${vars.appName}`,
        html: mjml(`<mjml>...${vars.url}...</mjml>`).html,
        text: `Sign in: ${vars.url}\nThis link expires in ${vars.minutes} minutes.`,
      }
    }
    // ... other templates
  }
}

Or pass i18n into the channel constructor and let the resolver render strings - see i18n.

Error handling

Transient SMTP failures (connection refused, timeout) raise AUTH/PROVIDER_FAILED. The provider re-throws so the client can retry.

Permanent failures (550 5.1.1 user unknown) also raise AUTH/PROVIDER_FAILED - duck-auth doesn't distinguish bounce categories because the local error code can be ambiguous; surface the underlying provider.errorCode from the meta if you need to branch.

Bounce handling

For SES, Postmark, or other providers that send asynchronous bounce webhooks, wire those into your own ingestion pipeline and call:

await auth.identities.markEmailUndeliverable(identityId, { reason: 'bounce' })

This sets a flag on the identity that the magic-link and email-verify providers respect on the next attempt.

Multi-SMTP fallback

If you operate two SMTP relays for redundancy, pick whichever has better deliverability:

class MultiSmtpChannel implements AuthChannel.IChannel {
  constructor(private channels: AuthSmtpChannel[]) {}
  async send(input) {
    for (const ch of this.channels) {
      try {
        return await ch.send(input)
      } catch (err) {
        continue
      }
    }
    return { ok: false, providerMessageId: '' }
  }
}

Or shard by region / tenant - pass a different AuthSmtpChannel per tenant if you have customer-specific routing.