Skip to main content

Transactional auth email

Deliver reset, verification, one-time code, and invitation email through one typed hook.

Pass one email hook to createBetterConvexAuth. The library calls it for every auth email that an enabled capability sends. The application is responsible for recipients, templates, delivery, and retries. Better Convex does not install a sending provider or render email.

Add the email hook

convex/auth.ts
import { createBetterConvexAuth } from '@lupinum/better-convex-nuxt/better-auth/server'

import { components, internal } from './_generated/api'
import type { DataModel } from './_generated/dataModel'

export const auth = createBetterConvexAuth<DataModel>(components.betterAuth, {
  emailAndPassword: {
    requireEmailVerification: true,
    passwordReset: true,
    revokeSessionsOnPasswordReset: true,
  },
  emailVerification: { sendOnSignUp: true },
  email: async (ctx, message) => {
    switch (message.type) {
      case 'verify-email':
      case 'reset-password':
        await ctx.runMutation(internal.authMail.submitLink, {
          kind: message.type,
          recipient: message.to,
          url: message.url,
        })
        return
      default:
        throw new Error(`Unsupported auth email: ${message.type}`)
    }
  },
})

internal.authMail.submitLink is an application-owned internal mutation in this example, not a library export. It must durably enqueue or schedule the approved message, with bounded retry and secret-retention rules. Do not expose a public endpoint that accepts arbitrary recipients or links.

Message types

message.to is always the recipient address. The other fields depend on message.type:

typeSent whenFields
verify-emailEmail verification is requestedurl, token, user
reset-passwordA password reset is requestedurl, token, user
email-otpThe emailOTP capability sends a codeotp, purpose
two-factor-otpThe twoFactor capability sends a one-time codeotp, user
organization-invitationThe organization capability invites a memberinvitationId, role, organization, inviter

purpose is sign-in, email-verification, forget-password, or change-email. user and inviter contain id, email, and name. An invitation has no link. Build it from your application origin and invitationId, for example with requireAuthOrigin('SITE_URL').

When you set email, email verification is enabled for emailAndPassword. Password reset is opt-in: set emailAndPassword.passwordReset: true to offer /request-password-reset and receive reset-password messages. Enabling it without email is a configuration error. Handle every type that your enabled capabilities can send.

Submit durable work

The hook receives a mutation or action context. The library fails with AUTH_EMAIL_REQUIRES_WRITABLE_CONTEXT before it calls the hook from a query. Use ctx.runMutation to submit durable work.

The library awaits the hook, but a rejected promise does not fail the Better Auth request. Better Auth runs every auth email as a background task and only logs a failure, so the user still sees success (this also keeps password reset from revealing which addresses have accounts). Treat the hook as a durable submission that must not fail: enqueue the work, then retry delivery from your own queue.

When the hook rejects, the library logs AUTH_EMAIL_DELIVERY_FAILED with the message type and a sanitized cause, and hands Better Auth only that static error. The raw error, which may echo a reset URL, token, or code, is never logged. Do not await a mail provider's network response or start an untracked promise. Auth token creation and a later submission can be separate transactions; test submission failure and safe user retries in the application. Never include the credential-bearing message in an error.

Configure the capabilities

The library sets the email callbacks of Better Auth. These options are rejected: emailAndPassword.sendResetPassword, emailVerification.sendVerificationEmail, emailOTP.sendVerificationOTP, twoFactor.otpOptions.sendOTP, and organization.sendInvitationEmail. Enabling emailOTP without email is a configuration error.

emailAndPassword, emailVerification, and emailOTP take plain options objects. Password hashing, minimum length, automatic sign-in, and the other settings that the library sets remain protected.

Use environment-independent build options for schema generation. Every enabled plugin must match the generated local component schema.

Email codes need their own expiry, abuse limits, and delivery-latency tests. A durable queue does not automatically stop sending after a code expires. Never log codes, reset URLs, or queued message bodies in application diagnostics.