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
emailequalsunverified_email, the account is inactive. The user signed up but has not verified their address yet. - When
unverified_emailisnull, the account is active. The primary email is verified and there is no pending change. - When
unverified_emaildiffers fromemail, 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.
-
Add the unverified email column to users
Generate a migration to add an
unverified_emailcolumn to your existinguserstable. This column stores a pending email address awaiting verification.node ace make:migration add_unverified_email_to_users --alter=usersdatabase/migrations/xxxx_add_unverified_email_to_users.tsimport { 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') }) } } } -
Create the email verification tokens table
Generate a migration for the
email_verification_tokenstable. This table stores hashed verification tokens, each tied to a user and an email address.node ace make:migration create_email_verification_tokensdatabase/migrations/xxxx_create_email_verification_tokens.tsimport { 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) } } -
Apply the mixin
Apply the
withManagedEmailmixin to your User model usingcompose.app/models/user.tsimport { 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.tsexport default class User extends compose( UserSchema, withManagedEmail({ table: 'custom_email_tokens', expiresIn: '2 hours', }) ) {}tablestringThe database table used to store email verification tokens.
Defaults to
"email_verification_tokens".tokenSecretLengthnumberThe length of the cryptographically secure random string used as the token secret.
Defaults to
40.expiresInstring | numberHow 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.
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/i18nconfigured, the error message is translated using theerrors.E_INVALID_EMAIL_TOKENkey.
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:
- Skipped: The new email is the same as the current one. Nothing to do.
- Reverted: The user changed back to their original verified email. Clear the pending change and delete all tokens.
- 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
The verified primary email address. Mapped to the email database column.
unverifiedEmail
The pending email address awaiting verification. Mapped to the unverified_email database column. null when there is no pending change.
hasActiveAccount
true when the primary email is verified (unverifiedEmail is null or differs from email).
hasInactiveAccount
true when the primary email has not been verified (email equals unverifiedEmail).
createEmailVerificationToken
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
Deletes all email verification tokens for the user. Returns the number of deleted rows.
await user.clearEmailVerificationTokens()
hasEmailChanged
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
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
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
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
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
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' })