use async_trait::async_trait;
use chrono::{Duration, Utc};
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use uuid::Uuid;
use better_auth_core::{AuthContext, AuthError, AuthResult};
use better_auth_core::{AuthRequest, AuthResponse, CreateVerification};
use better_auth_core::{AuthUser, DatabaseAdapter, User};
use better_auth_core::utils::cookie_utils::create_session_cookie;
use super::StatusResponse;
pub(super) mod handlers;
pub(super) mod types;
#[cfg(test)]
mod tests;
use handlers::*;
use types::*;
#[async_trait]
pub trait SendVerificationEmail: Send + Sync {
async fn send(&self, user: &User, url: &str, token: &str) -> AuthResult<()>;
}
pub type EmailVerificationHook =
Arc<dyn Fn(&User) -> Pin<Box<dyn Future<Output = AuthResult<()>> + Send>> + Send + Sync>;
pub struct EmailVerificationPlugin {
config: EmailVerificationConfig,
}
#[derive(better_auth_core::PluginConfig)]
#[plugin(name = "EmailVerificationPlugin")]
pub struct EmailVerificationConfig {
#[config(default = Duration::hours(24))]
pub verification_token_expiry: Duration,
#[config(default = true)]
pub send_email_notifications: bool,
#[config(default = false)]
pub require_verification_for_signin: bool,
#[config(default = false)]
pub auto_verify_new_users: bool,
#[config(default = false)]
pub send_on_sign_in: bool,
#[config(default = false)]
pub auto_sign_in_after_verification: bool,
#[config(default = None, skip)]
pub send_verification_email: Option<Arc<dyn SendVerificationEmail>>,
#[config(default = None)]
pub before_email_verification: Option<EmailVerificationHook>,
#[config(default = None)]
pub after_email_verification: Option<EmailVerificationHook>,
}
impl EmailVerificationConfig {
pub fn expiry_hours(&self) -> i64 {
self.verification_token_expiry.num_hours()
}
}
impl EmailVerificationPlugin {
pub fn verification_token_expiry_hours(mut self, hours: i64) -> Self {
self.config.verification_token_expiry = Duration::hours(hours);
self
}
pub fn custom_send_verification_email(
mut self,
sender: Arc<dyn SendVerificationEmail>,
) -> Self {
self.config.send_verification_email = Some(sender);
self
}
}
better_auth_core::impl_auth_plugin! {
EmailVerificationPlugin, "email-verification";
routes {
post "/send-verification-email" => handle_send_verification_email, "send_verification_email";
get "/verify-email" => handle_verify_email, "verify_email";
}
extra {
async fn on_user_created(&self, user: &DB::User, ctx: &AuthContext<DB>) -> AuthResult<()> {
if (self.config.send_email_notifications || self.config.send_verification_email.is_some())
&& !user.email_verified()
&& let Some(email) = user.email()
&& let Err(e) = self
.send_verification_email_for_user(user, email, None, ctx)
.await
{
tracing::warn!(
email = %email,
error = %e,
"Failed to send verification email"
);
}
Ok(())
}
}
}
impl EmailVerificationPlugin {
async fn handle_send_verification_email<DB: DatabaseAdapter>(
&self,
req: &AuthRequest,
ctx: &AuthContext<DB>,
) -> AuthResult<AuthResponse> {
let body: SendVerificationEmailRequest = match better_auth_core::validate_request_body(req)
{
Ok(v) => v,
Err(resp) => return Ok(resp),
};
let response = send_verification_email_core(&body, &self.config, ctx).await?;
Ok(AuthResponse::json(200, &response)?)
}
async fn handle_verify_email<DB: DatabaseAdapter>(
&self,
req: &AuthRequest,
ctx: &AuthContext<DB>,
) -> AuthResult<AuthResponse> {
let token = req
.query
.get("token")
.ok_or_else(|| AuthError::bad_request("Verification token is required"))?;
let callback_url = req.query.get("callbackURL").cloned();
let query = VerifyEmailQuery {
token: token.clone(),
callback_url,
};
let ip_address = req.headers.get("x-forwarded-for").cloned();
let user_agent = req.headers.get("user-agent").cloned();
match verify_email_core(&query, &self.config, ip_address, user_agent, ctx).await? {
VerifyEmailResult::AlreadyVerified(data) => Ok(AuthResponse::json(200, &data)?),
VerifyEmailResult::Redirect { url, session_token } => {
let mut headers = std::collections::HashMap::new();
headers.insert("Location".to_string(), url);
if let Some(token) = session_token {
let cookie = create_session_cookie(&token, &ctx.config);
headers.insert("Set-Cookie".to_string(), cookie);
}
Ok(AuthResponse {
status: 302,
headers,
body: Vec::new(),
})
}
VerifyEmailResult::Json(data) => Ok(AuthResponse::json(200, &data)?),
VerifyEmailResult::JsonWithSession {
response,
session_token,
} => {
let cookie = create_session_cookie(&session_token, &ctx.config);
Ok(AuthResponse::json(200, &response)?.with_header("Set-Cookie", cookie))
}
}
}
async fn send_verification_email_for_user<DB: DatabaseAdapter>(
&self,
user: &DB::User,
email: &str,
callback_url: Option<&str>,
ctx: &AuthContext<DB>,
) -> AuthResult<()> {
let verification_token = format!("verify_{}", Uuid::new_v4());
let expires_at = Utc::now() + self.config.verification_token_expiry;
let create_verification = CreateVerification {
identifier: email.to_string(),
value: verification_token.clone(),
expires_at,
};
ctx.database
.create_verification(create_verification)
.await?;
let verification_url = if let Some(callback_url) = callback_url {
format!("{}?token={}", callback_url, verification_token)
} else {
format!(
"{}/verify-email?token={}",
ctx.config.base_url, verification_token
)
};
if let Some(ref custom_sender) = self.config.send_verification_email {
let user = User::from(user);
custom_sender
.send(&user, &verification_url, &verification_token)
.await?;
} else if self.config.send_email_notifications {
if ctx.email_provider.is_some() {
let subject = "Verify your email address";
let html = format!(
"<p>Click the link below to verify your email address:</p>\
<p><a href=\"{url}\">Verify Email</a></p>",
url = verification_url
);
let text = format!("Verify your email address: {}", verification_url);
ctx.email_provider()?
.send(email, subject, &html, &text)
.await?;
} else {
tracing::warn!(
email = %email,
"No email provider configured, skipping verification email"
);
}
}
Ok(())
}
pub async fn send_verification_on_sign_in<DB: DatabaseAdapter>(
&self,
user: &DB::User,
callback_url: Option<&str>,
ctx: &AuthContext<DB>,
) -> AuthResult<()> {
if !self.config.send_on_sign_in {
return Ok(());
}
if user.email_verified() {
return Ok(());
}
if let Some(email) = user.email() {
self.send_verification_email_for_user(user, email, callback_url, ctx)
.await?;
}
Ok(())
}
pub fn should_send_on_sign_in(&self) -> bool {
self.config.send_on_sign_in
}
pub fn is_verification_required(&self) -> bool {
self.config.require_verification_for_signin
}
pub async fn is_user_verified_or_not_required(&self, user: &impl AuthUser) -> bool {
user.email_verified() || !self.config.require_verification_for_signin
}
}
#[cfg(feature = "axum")]
mod axum_impl {
use super::*;
use std::sync::Arc;
use axum::extract::{Extension, Query, State};
use axum::response::IntoResponse;
use axum::{Json, http::header};
use better_auth_core::{AuthError, AuthState, ValidatedJson};
struct PluginState {
config: EmailVerificationConfig,
}
fn clone_config(c: &EmailVerificationConfig) -> EmailVerificationConfig {
EmailVerificationConfig {
verification_token_expiry: c.verification_token_expiry,
send_email_notifications: c.send_email_notifications,
require_verification_for_signin: c.require_verification_for_signin,
auto_verify_new_users: c.auto_verify_new_users,
send_on_sign_in: c.send_on_sign_in,
auto_sign_in_after_verification: c.auto_sign_in_after_verification,
send_verification_email: c.send_verification_email.clone(),
before_email_verification: c.before_email_verification.clone(),
after_email_verification: c.after_email_verification.clone(),
}
}
async fn handle_send_verification_email<DB: DatabaseAdapter>(
State(state): State<AuthState<DB>>,
Extension(ps): Extension<Arc<PluginState>>,
ValidatedJson(body): ValidatedJson<SendVerificationEmailRequest>,
) -> Result<Json<StatusResponse>, AuthError> {
let ctx = state.to_context();
let response = send_verification_email_core(&body, &ps.config, &ctx).await?;
Ok(Json(response))
}
async fn handle_verify_email<DB: DatabaseAdapter>(
State(state): State<AuthState<DB>>,
Extension(ps): Extension<Arc<PluginState>>,
Query(query): Query<VerifyEmailQuery>,
) -> Result<axum::response::Response, AuthError> {
let ctx = state.to_context();
match verify_email_core(&query, &ps.config, None, None, &ctx).await? {
VerifyEmailResult::AlreadyVerified(data) => Ok(Json(data).into_response()),
VerifyEmailResult::Redirect { url, session_token } => {
if let Some(token) = session_token {
let cookie = state.session_cookie(&token);
Ok((
[(header::SET_COOKIE, cookie)],
axum::response::Redirect::to(&url),
)
.into_response())
} else {
Ok(axum::response::Redirect::to(&url).into_response())
}
}
VerifyEmailResult::Json(data) => Ok(Json(data).into_response()),
VerifyEmailResult::JsonWithSession {
response,
session_token,
} => {
let cookie = state.session_cookie(&session_token);
Ok(([(header::SET_COOKIE, cookie)], Json(response)).into_response())
}
}
}
#[async_trait::async_trait]
impl<DB: DatabaseAdapter> better_auth_core::AxumPlugin<DB> for EmailVerificationPlugin {
fn name(&self) -> &'static str {
"email-verification"
}
fn router(&self) -> axum::Router<AuthState<DB>> {
use axum::routing::{get, post};
let plugin_state = Arc::new(PluginState {
config: clone_config(&self.config),
});
axum::Router::new()
.route(
"/send-verification-email",
post(handle_send_verification_email::<DB>),
)
.route("/verify-email", get(handle_verify_email::<DB>))
.layer(Extension(plugin_state))
}
async fn on_user_created(
&self,
user: &DB::User,
ctx: &better_auth_core::AuthContext<DB>,
) -> better_auth_core::AuthResult<()> {
if (self.config.send_email_notifications
|| self.config.send_verification_email.is_some())
&& !user.email_verified()
&& let Some(email) = user.email()
&& let Err(e) = self
.send_verification_email_for_user(user, email, None, ctx)
.await
{
tracing::warn!(
email = %email,
error = %e,
"Failed to send verification email"
);
}
Ok(())
}
}
}