Skip to main content

better_auth_api/plugins/email_verification/
mod.rs

1use async_trait::async_trait;
2use chrono::{Duration, Utc};
3use std::future::Future;
4use std::pin::Pin;
5use std::sync::Arc;
6use uuid::Uuid;
7
8use better_auth_core::{AuthContext, AuthError, AuthResult};
9use better_auth_core::{AuthRequest, AuthResponse, CreateVerification};
10use better_auth_core::{AuthUser, DatabaseAdapter, User};
11
12use better_auth_core::utils::cookie_utils::create_session_cookie;
13
14use super::StatusResponse;
15
16pub(super) mod handlers;
17pub(super) mod types;
18
19#[cfg(test)]
20mod tests;
21
22use handlers::*;
23use types::*;
24
25/// Trait for custom email sending logic.
26///
27/// When set on [`EmailVerificationConfig::send_verification_email`], this
28/// callback overrides the default `EmailProvider`-based sending.
29#[async_trait]
30pub trait SendVerificationEmail: Send + Sync {
31    async fn send(&self, user: &User, url: &str, token: &str) -> AuthResult<()>;
32}
33
34/// Shorthand for the async hook closure type used by
35/// [`EmailVerificationConfig::before_email_verification`] and
36/// [`EmailVerificationConfig::after_email_verification`].
37pub type EmailVerificationHook =
38    Arc<dyn Fn(&User) -> Pin<Box<dyn Future<Output = AuthResult<()>> + Send>> + Send + Sync>;
39
40/// Email verification plugin for handling email verification flows
41pub struct EmailVerificationPlugin {
42    config: EmailVerificationConfig,
43}
44
45#[derive(better_auth_core::PluginConfig)]
46#[plugin(name = "EmailVerificationPlugin")]
47pub struct EmailVerificationConfig {
48    /// How long a verification token stays valid. Default: 24 hours.
49    #[config(default = Duration::hours(24))]
50    pub verification_token_expiry: Duration,
51    /// Whether to send email notifications (on sign-up). Default: true.
52    #[config(default = true)]
53    pub send_email_notifications: bool,
54    /// Whether email verification is required before sign-in. Default: false.
55    #[config(default = false)]
56    pub require_verification_for_signin: bool,
57    /// Whether to auto-verify newly created users. Default: false.
58    #[config(default = false)]
59    pub auto_verify_new_users: bool,
60    /// When true, automatically send a verification email on sign-in if the
61    /// user is unverified. Default: false.
62    #[config(default = false)]
63    pub send_on_sign_in: bool,
64    /// When true, create a session after email verification and return the
65    /// session token in the verify-email response. Default: false.
66    #[config(default = false)]
67    pub auto_sign_in_after_verification: bool,
68    /// Optional custom email sender. When set this overrides the default
69    /// `EmailProvider`-based sending.
70    #[config(default = None, skip)]
71    pub send_verification_email: Option<Arc<dyn SendVerificationEmail>>,
72    /// Hook invoked **before** email verification (before updating the user).
73    #[config(default = None)]
74    pub before_email_verification: Option<EmailVerificationHook>,
75    /// Hook invoked **after** email verification (after the user has been updated).
76    #[config(default = None)]
77    pub after_email_verification: Option<EmailVerificationHook>,
78}
79
80impl EmailVerificationConfig {
81    /// Backward-compatible helper: return the expiry duration expressed as
82    /// whole hours (truncated).
83    pub fn expiry_hours(&self) -> i64 {
84        self.verification_token_expiry.num_hours()
85    }
86}
87
88impl EmailVerificationPlugin {
89    /// Backward-compatible builder: set token expiry in hours.
90    pub fn verification_token_expiry_hours(mut self, hours: i64) -> Self {
91        self.config.verification_token_expiry = Duration::hours(hours);
92        self
93    }
94
95    pub fn custom_send_verification_email(
96        mut self,
97        sender: Arc<dyn SendVerificationEmail>,
98    ) -> Self {
99        self.config.send_verification_email = Some(sender);
100        self
101    }
102}
103
104better_auth_core::impl_auth_plugin! {
105    EmailVerificationPlugin, "email-verification";
106    routes {
107        post "/send-verification-email" => handle_send_verification_email, "send_verification_email";
108        get "/verify-email" => handle_verify_email, "verify_email";
109    }
110    extra {
111        async fn on_user_created(&self, user: &DB::User, ctx: &AuthContext<DB>) -> AuthResult<()> {
112            // Send verification email for new users if configured.
113            // Also fire when a custom sender is set, even if send_email_notifications is false.
114            if (self.config.send_email_notifications || self.config.send_verification_email.is_some())
115                && !user.email_verified()
116                && let Some(email) = user.email()
117                && let Err(e) = self
118                    .send_verification_email_for_user(user, email, None, ctx)
119                    .await
120            {
121                tracing::warn!(
122                    email = %email,
123                    error = %e,
124                    "Failed to send verification email"
125                );
126            }
127            Ok(())
128        }
129    }
130}
131
132// ---------------------------------------------------------------------------
133// Route handlers (delegate to core functions)
134// ---------------------------------------------------------------------------
135
136impl EmailVerificationPlugin {
137    async fn handle_send_verification_email<DB: DatabaseAdapter>(
138        &self,
139        req: &AuthRequest,
140        ctx: &AuthContext<DB>,
141    ) -> AuthResult<AuthResponse> {
142        let body: SendVerificationEmailRequest = match better_auth_core::validate_request_body(req)
143        {
144            Ok(v) => v,
145            Err(resp) => return Ok(resp),
146        };
147        let response = send_verification_email_core(&body, &self.config, ctx).await?;
148        Ok(AuthResponse::json(200, &response)?)
149    }
150
151    async fn handle_verify_email<DB: DatabaseAdapter>(
152        &self,
153        req: &AuthRequest,
154        ctx: &AuthContext<DB>,
155    ) -> AuthResult<AuthResponse> {
156        let token = req
157            .query
158            .get("token")
159            .ok_or_else(|| AuthError::bad_request("Verification token is required"))?;
160        let callback_url = req.query.get("callbackURL").cloned();
161        let query = VerifyEmailQuery {
162            token: token.clone(),
163            callback_url,
164        };
165
166        let ip_address = req.headers.get("x-forwarded-for").cloned();
167        let user_agent = req.headers.get("user-agent").cloned();
168
169        match verify_email_core(&query, &self.config, ip_address, user_agent, ctx).await? {
170            VerifyEmailResult::AlreadyVerified(data) => Ok(AuthResponse::json(200, &data)?),
171            VerifyEmailResult::Redirect { url, session_token } => {
172                let mut headers = std::collections::HashMap::new();
173                headers.insert("Location".to_string(), url);
174                if let Some(token) = session_token {
175                    let cookie = create_session_cookie(&token, &ctx.config);
176                    headers.insert("Set-Cookie".to_string(), cookie);
177                }
178                Ok(AuthResponse {
179                    status: 302,
180                    headers,
181                    body: Vec::new(),
182                })
183            }
184            VerifyEmailResult::Json(data) => Ok(AuthResponse::json(200, &data)?),
185            VerifyEmailResult::JsonWithSession {
186                response,
187                session_token,
188            } => {
189                let cookie = create_session_cookie(&session_token, &ctx.config);
190                Ok(AuthResponse::json(200, &response)?.with_header("Set-Cookie", cookie))
191            }
192        }
193    }
194
195    /// Send a verification email for a specific user.
196    ///
197    /// If [`EmailVerificationConfig::send_verification_email`] is set the
198    /// custom callback is used; otherwise the default `EmailProvider` path is
199    /// taken.
200    async fn send_verification_email_for_user<DB: DatabaseAdapter>(
201        &self,
202        user: &DB::User,
203        email: &str,
204        callback_url: Option<&str>,
205        ctx: &AuthContext<DB>,
206    ) -> AuthResult<()> {
207        // Generate verification token
208        let verification_token = format!("verify_{}", Uuid::new_v4());
209        let expires_at = Utc::now() + self.config.verification_token_expiry;
210
211        // Create verification token
212        let create_verification = CreateVerification {
213            identifier: email.to_string(),
214            value: verification_token.clone(),
215            expires_at,
216        };
217
218        ctx.database
219            .create_verification(create_verification)
220            .await?;
221
222        let verification_url = if let Some(callback_url) = callback_url {
223            format!("{}?token={}", callback_url, verification_token)
224        } else {
225            format!(
226                "{}/verify-email?token={}",
227                ctx.config.base_url, verification_token
228            )
229        };
230
231        // Use custom sender if configured, otherwise fall back to EmailProvider
232        if let Some(ref custom_sender) = self.config.send_verification_email {
233            let user = User::from(user);
234            custom_sender
235                .send(&user, &verification_url, &verification_token)
236                .await?;
237        } else if self.config.send_email_notifications {
238            // Gracefully skip if no email provider is configured
239            if ctx.email_provider.is_some() {
240                let subject = "Verify your email address";
241                let html = format!(
242                    "<p>Click the link below to verify your email address:</p>\
243                     <p><a href=\"{url}\">Verify Email</a></p>",
244                    url = verification_url
245                );
246                let text = format!("Verify your email address: {}", verification_url);
247
248                ctx.email_provider()?
249                    .send(email, subject, &html, &text)
250                    .await?;
251            } else {
252                tracing::warn!(
253                    email = %email,
254                    "No email provider configured, skipping verification email"
255                );
256            }
257        }
258
259        Ok(())
260    }
261
262    /// Send a verification email on sign-in for an unverified user.
263    ///
264    /// Callers (e.g. the sign-in plugin) should invoke this when
265    /// [`EmailVerificationConfig::send_on_sign_in`] is `true` and the user is
266    /// not yet verified.
267    pub async fn send_verification_on_sign_in<DB: DatabaseAdapter>(
268        &self,
269        user: &DB::User,
270        callback_url: Option<&str>,
271        ctx: &AuthContext<DB>,
272    ) -> AuthResult<()> {
273        if !self.config.send_on_sign_in {
274            return Ok(());
275        }
276
277        if user.email_verified() {
278            return Ok(());
279        }
280
281        if let Some(email) = user.email() {
282            self.send_verification_email_for_user(user, email, callback_url, ctx)
283                .await?;
284        }
285
286        Ok(())
287    }
288
289    /// Check if `send_on_sign_in` is enabled.
290    pub fn should_send_on_sign_in(&self) -> bool {
291        self.config.send_on_sign_in
292    }
293
294    /// Check if email verification is required for signin
295    pub fn is_verification_required(&self) -> bool {
296        self.config.require_verification_for_signin
297    }
298
299    /// Check if user is verified or verification is not required
300    pub async fn is_user_verified_or_not_required(&self, user: &impl AuthUser) -> bool {
301        user.email_verified() || !self.config.require_verification_for_signin
302    }
303}
304
305// ---------------------------------------------------------------------------
306// Axum plugin
307// ---------------------------------------------------------------------------
308
309#[cfg(feature = "axum")]
310mod axum_impl {
311    use super::*;
312    use std::sync::Arc;
313
314    use axum::extract::{Extension, Query, State};
315    use axum::response::IntoResponse;
316    use axum::{Json, http::header};
317    use better_auth_core::{AuthError, AuthState, ValidatedJson};
318
319    /// Plugin state stored as an axum extension.
320    ///
321    /// `EmailVerificationConfig` is NOT Clone (callback fields are `Arc<dyn ...>`
322    /// without `Clone`), so we clone each field individually.
323    struct PluginState {
324        config: EmailVerificationConfig,
325    }
326
327    fn clone_config(c: &EmailVerificationConfig) -> EmailVerificationConfig {
328        EmailVerificationConfig {
329            verification_token_expiry: c.verification_token_expiry,
330            send_email_notifications: c.send_email_notifications,
331            require_verification_for_signin: c.require_verification_for_signin,
332            auto_verify_new_users: c.auto_verify_new_users,
333            send_on_sign_in: c.send_on_sign_in,
334            auto_sign_in_after_verification: c.auto_sign_in_after_verification,
335            send_verification_email: c.send_verification_email.clone(),
336            before_email_verification: c.before_email_verification.clone(),
337            after_email_verification: c.after_email_verification.clone(),
338        }
339    }
340
341    async fn handle_send_verification_email<DB: DatabaseAdapter>(
342        State(state): State<AuthState<DB>>,
343        Extension(ps): Extension<Arc<PluginState>>,
344        ValidatedJson(body): ValidatedJson<SendVerificationEmailRequest>,
345    ) -> Result<Json<StatusResponse>, AuthError> {
346        let ctx = state.to_context();
347        let response = send_verification_email_core(&body, &ps.config, &ctx).await?;
348        Ok(Json(response))
349    }
350
351    async fn handle_verify_email<DB: DatabaseAdapter>(
352        State(state): State<AuthState<DB>>,
353        Extension(ps): Extension<Arc<PluginState>>,
354        Query(query): Query<VerifyEmailQuery>,
355    ) -> Result<axum::response::Response, AuthError> {
356        let ctx = state.to_context();
357        // Note: axum Query extractor doesn't give us headers; pass None for
358        // ip/user-agent (these are only used for session creation metadata).
359        match verify_email_core(&query, &ps.config, None, None, &ctx).await? {
360            VerifyEmailResult::AlreadyVerified(data) => Ok(Json(data).into_response()),
361            VerifyEmailResult::Redirect { url, session_token } => {
362                if let Some(token) = session_token {
363                    let cookie = state.session_cookie(&token);
364                    Ok((
365                        [(header::SET_COOKIE, cookie)],
366                        axum::response::Redirect::to(&url),
367                    )
368                        .into_response())
369                } else {
370                    Ok(axum::response::Redirect::to(&url).into_response())
371                }
372            }
373            VerifyEmailResult::Json(data) => Ok(Json(data).into_response()),
374            VerifyEmailResult::JsonWithSession {
375                response,
376                session_token,
377            } => {
378                let cookie = state.session_cookie(&session_token);
379                Ok(([(header::SET_COOKIE, cookie)], Json(response)).into_response())
380            }
381        }
382    }
383
384    #[async_trait::async_trait]
385    impl<DB: DatabaseAdapter> better_auth_core::AxumPlugin<DB> for EmailVerificationPlugin {
386        fn name(&self) -> &'static str {
387            "email-verification"
388        }
389
390        fn router(&self) -> axum::Router<AuthState<DB>> {
391            use axum::routing::{get, post};
392
393            let plugin_state = Arc::new(PluginState {
394                config: clone_config(&self.config),
395            });
396
397            axum::Router::new()
398                .route(
399                    "/send-verification-email",
400                    post(handle_send_verification_email::<DB>),
401                )
402                .route("/verify-email", get(handle_verify_email::<DB>))
403                .layer(Extension(plugin_state))
404        }
405
406        async fn on_user_created(
407            &self,
408            user: &DB::User,
409            ctx: &better_auth_core::AuthContext<DB>,
410        ) -> better_auth_core::AuthResult<()> {
411            // Delegate to the AuthPlugin implementation logic
412            if (self.config.send_email_notifications
413                || self.config.send_verification_email.is_some())
414                && !user.email_verified()
415                && let Some(email) = user.email()
416                && let Err(e) = self
417                    .send_verification_email_for_user(user, email, None, ctx)
418                    .await
419            {
420                tracing::warn!(
421                    email = %email,
422                    error = %e,
423                    "Failed to send verification email"
424                );
425            }
426            Ok(())
427        }
428    }
429}