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
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:
type | Sent when | Fields |
|---|---|---|
verify-email | Email verification is requested | url, token, user |
reset-password | A password reset is requested | url, token, user |
email-otp | The emailOTP capability sends a code | otp, purpose |
two-factor-otp | The twoFactor capability sends a one-time code | otp, user |
organization-invitation | The organization capability invites a member | invitationId, 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.