Email management

This guide covers the withManagedEmail mixin from @adonisplus/persona/email. You will learn how to:

  • Apply the mixin and understand the two-column email pattern
  • Create users with unverified email addresses
  • Verify emails using cryptographic tokens
  • Handle email change requests safely
  • Throttle token creation to prevent abuse

Overview

Most applications need to verify that a user owns the email address they signed up with. They also need a safe way to let users change their email later without losing access to their account.

The withManagedEmail mixin solves both problems using a two-column email pattern. Your users table stores two values: a verified email (the primary address) and an unverified_email (a pending address awaiting verification). This separation gives you a clear picture of the account state at any point in time.

  • When email equals unverified_email, the account is inactive. The user signed up but has not verified their address yet.
  • When unverified_email is null, the account is active. The primary email is verified and there is no pending change.
  • When unverified_email differs from email, the user has requested an email change. The primary address stays intact until the new one is verified.

Setup

The withManagedEmail mixin needs database tables for storing the pending email and verification tokens, and must be applied to your User model.

  1. Add the unverified email column to users

    Generate a migration to add an unverified_email column to your existing users table. This column stores a pending email address awaiting verification.

    node ace make:migration add_unverified_email_to_users --alter=users
    database/migrations/xxxx_add_unverified_email_to_users.ts
    import { BaseSchema } from '@adonisjs/lucid/schema'
    
    export default class extends BaseSchema {
      protected tableName = 'users'
    
      async up() {
        if (!(await this.schema.hasColumn('users', 'unverified_email'))) {
          this.schema.alterTable(this.tableName, (table) => {
            table.string('unverified_email', 254).nullable().after('email').index()
          })
        }
      }
    
      async down() {
        if (await this.schema.hasColumn('users', 'unverified_email')) {
          this.schema.table(this.tableName, (table) => {
            table.dropColumn('unverified_email')
          })
        }
      }
    }
  2. Create the email verification tokens table

    Generate a migration for the email_verification_tokens table. This table stores hashed verification tokens, each tied to a user and an email address.

    node ace make:migration create_email_verification_tokens
    database/migrations/xxxx_create_email_verification_tokens.ts
    import { BaseSchema } from '@adonisjs/lucid/schema'
    
    export default class extends BaseSchema {
      protected tableName = 'email_verification_tokens'
    
      async up() {
        this.schema.createTable(this.tableName, (table) => {
          table.increments('id')
          table.integer('tokenable_id').notNullable().unsigned()
          table.string('email').notNullable()
          table.string('hash', 80).notNullable()
          table.timestamp('created_at', { precision: 6, useTz: true }).notNullable()
          table.timestamp('expires_at', { precision: 6, useTz: true }).nullable()
        })
      }
    
      async down() {
        this.schema.dropTable(this.tableName)
      }
    }
  3. Apply the mixin

    Apply the withManagedEmail mixin to your User model using compose.

    app/models/user.ts
    import { UserSchema } from '#database/schema'
    import { compose } from '@adonisjs/core/helpers'
    import { withManagedEmail } from '@adonisplus/persona/email'
    
    export default class User extends compose(
      UserSchema,
      withManagedEmail() 
    ) {}

    You can customize the token provider by passing a config object.

    app/models/user.ts
    export default class User extends compose(
      UserSchema,
      withManagedEmail({
        table: 'custom_email_tokens',
        expiresIn: '2 hours',
      })
    ) {}
    table
    string

    The database table used to store email verification tokens.

    Defaults to "email_verification_tokens".

    tokenSecretLength
    number

    The length of the cryptographically secure random string used as the token secret.

    Defaults to 40.

    expiresIn
    string | number

    How long tokens remain valid. Accepts a number in seconds or a time expression string like '1 day' or '2 hours'.

    Defaults to '1 day'.

Creating users

When you create a new user, set both email and unverifiedEmail to the same value. This marks the account as inactive until the email is verified.

Warning

You must normalize email addresses to lowercase before passing them to the model. The module stores and compares emails exactly as given. If one user signs up with Alice@example.com and another with alice@example.com, the database may treat them as different values depending on your collation settings.

Per RFC 5321, the local part of an email address is technically case-sensitive. In practice, no major email provider enforces this. Lowercasing on input is the industry standard.

const { user, token } = await db.transaction(async (trx) => {
  const newUser = await User.create(
    {
      email,
      unverifiedEmail: email,
      password,
    },
    { client: trx }
  )

  const verificationToken = await newUser.createEmailVerificationToken()
  return { user: newUser, token: verificationToken }
})

// Send rawTokenValue in the email
const rawTokenValue = token.value!.release()

You can check the account state at any time using the computed boolean properties.

user.hasInactiveAccount // true when email === unverifiedEmail
user.hasActiveAccount // true when the above is false

Verifying emails

When the user clicks the verification link, pass the token string to the static verifyEmail method. This method verifies the token, checks that no other user already owns the email, and switches the user's email inside a database transaction.

const user = await User.verifyEmail(token)

/**
 * You must call `clearEmailVerificationTokens()` after
 * a successful verification.
 */
await user.clearEmailVerificationTokens()

If verification fails (invalid token, expired token, email already taken, or user not found), the method throws an E_INVALID_EMAIL_TOKEN exception. The error message is intentionally generic to prevent information leakage. The exception includes a built-in handle method that content-negotiates the response:

  • HTML requests: Flashes the error message to the session and redirects. You can customize the redirect URL by passing options.
  • JSON requests: Returns { errors: [{ message: "..." }] } with a 400 status.
  • JSONAPI requests: Returns { errors: [{ code: "E_INVALID_EMAIL_TOKEN", title: "..." }] }.
  • i18n: If you have @adonisjs/i18n configured, the error message is translated using the errors.E_INVALID_EMAIL_TOKEN key.
const user = await User.verifyEmail(token, {
  redirectTo: '/login',
})

Handling email changes

When an existing user wants to change their email, you need to handle three possible outcomes:

  1. Skipped: The new email is the same as the current one. Nothing to do.
  2. Reverted: The user changed back to their original verified email. Clear the pending change and delete all tokens.
  3. Issued token: The email is genuinely new. Update the pending email, clear old tokens, and issue a new verification token.

Use lockForUpdate to prevent race conditions during the update.

const result = await user.lockForUpdate(async (freshUser) => {
  // Email has not changed
  if (!freshUser.hasEmailChanged(newEmail)) {
    return { type: 'SKIPPED' } as const
  }

  // User reverted back to their verified email
  if (freshUser.hasEmailReverted(newEmail)) {
    freshUser.email = newEmail
    freshUser.unverifiedEmail = null
    await freshUser.save()
    await freshUser.clearEmailVerificationTokens()
    return { type: 'REVERTED' } as const
  }

  // New email requested
  await freshUser.withEmail(newEmail).save()
  await freshUser.clearEmailVerificationTokens()
  const token = await freshUser.createEmailVerificationToken()
  return { type: 'ISSUED_TOKEN', token } as const
})

// Send verification email if a new token was issued
if (result.type === 'ISSUED_TOKEN') {
  // Send result.token.value!.release() to the user via email
}

The withEmail method is aware of the account state. For inactive accounts (where email equals unverifiedEmail), it updates both columns so they stay in sync. For active accounts, it only updates unverifiedEmail, preserving the verified primary email until the new one is confirmed.

Resending verification emails

When resending verification emails, use the shouldThrottle parameter to prevent abuse. The first argument enables throttling. The second sets the throttle window (defaults to 60 seconds). When a token was created within the window, the method returns null instead of creating a new one.

// Returns null if a token was created within the throttle window
const token = await user.createEmailVerificationToken(true, '1 min')

if (token) {
  // Send token.value!.release() to the user via email
}

You should always enable throttling on user-facing endpoints. Without it, every call creates a new token, which lets a single user flood the tokens table.

Instance properties and methods

The withManagedEmail mixin adds the following to model instances.

email
string

The verified primary email address. Mapped to the email database column.

unverifiedEmail
string | null

The pending email address awaiting verification. Mapped to the unverified_email database column. null when there is no pending change.

hasActiveAccount
boolean

true when the primary email is verified (unverifiedEmail is null or differs from email).

hasInactiveAccount
boolean

true when the primary email has not been verified (email equals unverifiedEmail).

createEmailVerificationToken
method

Creates a verification token for the user's unverifiedEmail. Throws a RuntimeException if unverifiedEmail is null.

// Without throttling
const token = await user.createEmailVerificationToken()

// With throttling (returns null within the window)
const token = await user.createEmailVerificationToken(true, '1 min')
clearEmailVerificationTokens
method

Deletes all email verification tokens for the user. Returns the number of deleted rows.

await user.clearEmailVerificationTokens()
hasEmailChanged
method

Returns true if the provided email differs from the current pending email (or the primary email when no pending change exists).

user.hasEmailChanged('new@example.com')
hasEmailReverted
method

Returns true if the user is reverting back to their verified primary email. Only true when unverifiedEmail exists and the new email matches email.

user.hasEmailReverted('original@example.com')
withEmail
method

Updates the local email and unverifiedEmail attributes. For inactive accounts, updates both columns. For active accounts, only updates unverifiedEmail. Returns this for chaining.

await user.withEmail('new@example.com').save()
switchEmail
method

Sets the primary email and clears unverifiedEmail to null. Used internally by verifyEmail. Returns this for chaining.

user.switchEmail('verified@example.com')

Static properties and methods

emailVerificationTokens
DbEmailTokensProvider

The token provider instance attached to the model. You can use it directly for lower-level token operations.

const tokens = await User.emailVerificationTokens.all(user)
const lastCreated = await User.emailVerificationTokens.lastCreatedAt(user)
await User.emailVerificationTokens.delete(user, tokenId)
verifyEmail
static method

Verifies an email using a token value string. Runs inside a database transaction. Returns the user instance with the updated email.

Throws E_INVALID_EMAIL_TOKEN when the token is invalid, expired, the email is already taken by another user, or the user no longer has a matching unverifiedEmail.

const user = await User.verifyEmail(tokenValue)
const user = await User.verifyEmail(tokenValue, { redirectTo: '/login' })
Terms & License Agreement