AuthSmtpChannel
WIPNodemailer-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.
With magic-link
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.