Skip to main content

AWS SES channel

WIP

AWS Simple Email Service via @aws-sdk/client-ses.

Install

bun add @aws-sdk/client-ses

Setup

import { SESClient } from '@aws-sdk/client-ses'
import { AuthSesChannel } from '@gentleduck/auth/channels/ses'

const client = new SESClient({ region: 'us-east-1' })

const channel = new AuthSesChannel({
  client,
  from: 'no-reply@example.com',
  id: 'ses',
})

Verify the from address (or its domain) in the SES console. If your account is still in the sandbox, also verify recipient addresses.

IAM permissions

The IAM role / user running your service needs the ses:SendEmail permission (and ses:SendRawEmail if you send with custom headers / attachments):

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["ses:SendEmail", "ses:SendRawEmail"],
      "Resource": "*"
    }
  ]
}
auth.providers.use(
  magicLink({
    channels: { email: channel },
    findIdentityByEmail: (email) => auth.identities.findByEmail(email),
  }),
)

Configuration set / event publishing

For bounce + complaint webhooks, wire SES Configuration Sets and SNS:

  1. Create a configuration set in the SES console.
  2. Subscribe an SNS topic to bounce + complaint events.
  3. Subscribe your service to the SNS topic.
  4. Mark identities undeliverable from the handler:
app.post('/webhooks/ses', async (req, res) => {
  const event = parseSnsMessage(req.body)
  if (event.eventType === 'Bounce') {
    for (const recipient of event.bounce.bouncedRecipients) {
      const identity = await auth.identities.findByEmail(recipient.emailAddress)
      if (identity) {
        await auth.identities.markEmailUndeliverable(identity.id, { reason: 'bounce' })
      }
    }
  }
  res.json({ ok: true })
})

Throughput

SES has account-level send rate (msg/s) and daily quotas. Once you leave the sandbox, quotas auto-scale based on reputation. duck-auth itself does not throttle outbound; if you push above SES limits, SendEmail returns Throttling and AuthSesChannel raises AUTH/RATE_LIMITED.

For backfill / bulk transactional (rare in auth flows), shard sends across regions / accounts to stay under the per-account ceiling.