use chrono::Duration;
use std::sync::Arc;
use better_auth_core::AuthUser;
pub use better_auth_core::email::{EmailVerificationHook, SendVerificationEmail};
use better_auth_core::{AuthContext, AuthError, AuthResult};
use better_auth_core::{AuthRequest, AuthResponse};
use super::StatusResponse;
pub(super) mod handlers;
pub(crate) mod token;
pub(super) mod types;
mod callbacks;
pub(crate) mod delivery;
pub use callbacks::{EmailVerificationCallbacks, VerificationEmail};
#[cfg(test)]
mod native_tests;
#[cfg(test)]
mod tests;
use handlers::*;
use types::*;
pub struct EmailVerificationPlugin {
config: EmailVerificationConfig,
}
#[derive(Clone, better_auth_core::PluginConfig)]
#[plugin(name = "EmailVerificationPlugin")]
pub struct EmailVerificationConfig {
#[config(default = None)]
pub verification_token_expiry: Option<Duration>,
#[config(default = true)]
pub send_email_notifications: bool,
#[config(default = false)]
pub require_verification_for_signin: bool,
#[config(default = false)]
pub send_on_sign_in: bool,
#[config(default = None)]
pub send_on_sign_up: Option<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 verification_token_expiry(&self) -> Duration {
self.verification_token_expiry
.unwrap_or_else(|| Duration::hours(1))
}
}
impl EmailVerificationPlugin {
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, "sendVerificationEmail", body = types::body;
get "/verify-email" => handle_verify_email, "verifyEmail", query = crate::plugins::query_input::verify_email;
}
extra {
fn telemetry_plugin_id(&self) -> Option<&'static str> {
None
}
fn telemetry(&self, options: &mut better_auth_core::observability::telemetry::PluginTelemetry) {
let options = &mut options.email_verification;
options.expires_in = self.config.verification_token_expiry;
options.send_verification_email = self.config.send_verification_email.is_some();
options.send_on_sign_up = self.config.send_on_sign_up == Some(true);
options.send_on_sign_in = self.config.send_on_sign_in;
options.auto_sign_in_after_verification = self.config.auto_sign_in_after_verification;
options.before_email_verification = self.config.before_email_verification.is_some();
options.after_email_verification = self.config.after_email_verification.is_some();
}
async fn on_init(&self, ctx: &mut better_auth_core::AuthInitContext<S>) -> AuthResult<()> {
ctx.extensions.insert(self.config.clone());
ctx.email_verification_policy.auto_sign_in_after_verification = self.config.auto_sign_in_after_verification;
ctx.email_verification_policy.before_email_verification = self.config.before_email_verification.clone();
ctx.email_verification_policy.after_email_verification = self.config.after_email_verification.clone();
Ok(())
}
}
}
impl EmailVerificationPlugin {
pub(crate) fn from_context(
ctx: &AuthContext<impl better_auth_core::AuthSchema>,
) -> Option<Self> {
ctx.extensions
.get::<EmailVerificationConfig>()
.map(|config| Self::with_config(config.clone()))
}
pub(crate) async fn send_verification_on_sign_up<S: better_auth_core::AuthSchema>(
&self,
user: &impl AuthUser,
required: bool,
callback_url: Option<&str>,
endpoint: &crate::plugins::endpoint_context::EndpointContext<'_, S>,
) -> AuthResult<()> {
if self.config.send_on_sign_up.unwrap_or(required) {
self.send_verification_email_at(user, callback_url, endpoint)
.await?;
}
Ok(())
}
async fn handle_send_verification_email(
&self,
req: &AuthRequest,
ctx: &AuthContext<impl better_auth_core::AuthSchema>,
) -> AuthResult<AuthResponse> {
let body = match req.validated_body::<SendVerificationEmailRequest>() {
Some(body) => body.clone(),
None => super::json_body::email_input(req, "email", "callbackURL")?.0,
};
let mut config = self.config.clone();
if config.send_verification_email.is_none() {
config.send_verification_email = ctx.email_verification_policy.override_sender.clone();
}
let response = send_verification_email_core(&body, req, &config, ctx).await?;
AuthResponse::json(None, &response)
}
async fn handle_verify_email(
&self,
req: &AuthRequest,
ctx: &AuthContext<impl better_auth_core::AuthSchema>,
) -> AuthResult<AuthResponse> {
let token = req
.query_string("token")?
.ok_or_else(|| AuthError::bad_request("Verification token is required"))?;
let callback_url = req
.query_string("callbackURL")?
.filter(|url| !url.is_empty());
if let Some(ref url) = callback_url
&& !ctx.config.advanced.disable_origin_check
&& !ctx.is_redirect_target_trusted(url)
{
return Err(AuthError::from_code(
"FORBIDDEN",
"INVALID_CALLBACK_URL",
"Invalid callbackURL",
));
}
let query = VerifyEmailQuery {
token,
callback_url,
};
match verify_email_core(&query, &self.config, req, ctx).await? {
VerifyEmailResult::Redirect { url } => {
let mut headers = better_auth_core::Headers::new();
_ = headers.insert("Location".to_string(), url);
_ = headers.insert("content-type".to_string(), "application/json".to_string());
let mut response = AuthResponse::new(302);
response.headers = headers;
Ok(response)
}
VerifyEmailResult::Json { body } => Ok(AuthResponse::native(None, body)),
}
}
async fn send_verification_email_for_user(
&self,
user: &impl AuthUser,
callback_url: Option<&str>,
request: Option<&AuthRequest>,
ctx: &AuthContext<impl better_auth_core::AuthSchema>,
) -> AuthResult<()> {
let body = request
.filter(|request| request.body.is_some())
.map(|request| request.body_as_json())
.transpose()?
.unwrap_or(serde_json::Value::Null);
let endpoint = crate::plugins::endpoint_context::EndpointContext::new(
request,
better_auth_core::FieldValue::from_json(body)?,
ctx,
);
self.send_verification_email_at(user, callback_url, &endpoint)
.await
}
async fn send_verification_email_at<S: better_auth_core::AuthSchema>(
&self,
user: &impl AuthUser,
callback_url: Option<&str>,
endpoint: &crate::plugins::endpoint_context::EndpointContext<'_, S>,
) -> AuthResult<()> {
let ctx = endpoint.auth;
let email = crate::plugins::helpers::user_email(user)?;
let verification_token = token::create_email_verification_token(
ctx.config.signing_secret(),
&email,
None,
self.config.verification_token_expiry(),
None,
)?;
let callback_url = callback_url.unwrap_or("/");
let verification_url = format!(
"{}/verify-email?token={}&callbackURL={}",
ctx.base_url(),
verification_token,
urlencoding::encode(callback_url),
);
if ctx
.extensions
.get::<Arc<EmailVerificationCallbacks<S>>>()
.is_none_or(|callbacks| !callbacks.has_sender())
&& self.config.send_verification_email.is_none()
&& crate::plugins::email_otp::callbacks::overrides_verification(ctx)
{
let retained = endpoint.to_owned();
let email = email.to_owned();
better_auth_core::background::run_or_await(
Some(better_auth_core::background::BackgroundFuture::new(
async move {
let endpoint = retained.as_endpoint();
crate::plugins::email_otp::callbacks::send_verification_override(
&email, &endpoint,
)
.await
},
)),
ctx.config.advanced.background_tasks.as_ref(),
ctx.logger(),
)
.await?;
return Ok(());
}
let message = VerificationEmail {
user: better_auth_core::FieldMap::from(ctx.internal_user_view(user).await?).into(),
url: verification_url,
token: verification_token,
};
let task = delivery::delivery(Some(&self.config), message, endpoint)?;
better_auth_core::background::run_or_await(
task,
ctx.config.advanced.background_tasks.as_ref(),
ctx.logger(),
)
.await?;
Ok(())
}
pub async fn send_verification_on_sign_in(
&self,
user: &impl AuthUser,
callback_url: Option<&str>,
ctx: &AuthContext<impl better_auth_core::AuthSchema>,
) -> AuthResult<()> {
self.send_verification_on_sign_in_with_request(user, callback_url, None, ctx)
.await
}
pub(crate) async fn send_verification_on_sign_in_with_request(
&self,
user: &impl AuthUser,
callback_url: Option<&str>,
request: Option<&AuthRequest>,
ctx: &AuthContext<impl better_auth_core::AuthSchema>,
) -> AuthResult<()> {
if !self.config.send_on_sign_in {
return Ok(());
}
if user.email_verified().is_truthy()? {
return Ok(());
}
self.send_verification_email_for_user(user, callback_url, request, ctx)
.await?;
Ok(())
}
pub(crate) async fn send_verification_on_oauth_sign_in(
&self,
user: Option<&impl AuthUser>,
is_register: bool,
require_verification: bool,
callback_url: &str,
request: &AuthRequest,
ctx: &AuthContext<impl better_auth_core::AuthSchema>,
) -> AuthResult<()> {
let should_send = if is_register {
self.config.send_on_sign_up.unwrap_or(require_verification)
} else {
require_verification && self.config.send_on_sign_in
};
if !should_send
|| user.is_some_and(|user| user.email_verified().field_value().is_truthy())
|| !delivery::available(Some(&self.config), ctx)
{
return Ok(());
}
let result = async {
let user = user.ok_or_else(|| {
AuthError::internal(
"Cannot create an OAuth verification token without a user email",
)
})?;
self.send_verification_email_for_user(user, Some(callback_url), Some(request), ctx)
.await
}
.await;
if let Err(error) = result {
better_auth_core::observability::logger::current().error(
"Failed to send OAuth verification email",
&[better_auth_core::observability::LogArgument::Error(&error)],
)?;
}
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 fn is_user_verified_or_not_required(&self, user: &impl AuthUser) -> bool {
user.email_verified().field_value().is_truthy()
|| !self.config.require_verification_for_signin
}
}