Skip to main content

better_auth_api/plugins/email_verification/
mod.rs

1use async_trait::async_trait;
2use chrono::Duration;
3use std::future::Future;
4use std::pin::Pin;
5use std::sync::Arc;
6
7use better_auth_core::AuthUser;
8use better_auth_core::wire::UserView;
9use better_auth_core::{AuthContext, AuthError, AuthResult};
10use better_auth_core::{AuthRequest, AuthResponse};
11
12use better_auth_core::utils::cookie_utils::create_session_cookie;
13
14use super::StatusResponse;
15
16pub(super) mod handlers;
17pub(crate) mod token;
18pub(super) mod types;
19
20#[cfg(test)]
21mod tests;
22
23use handlers::*;
24use types::*;
25
26/// Trait for custom email sending logic.
27///
28/// When set on [`EmailVerificationConfig::send_verification_email`], this
29/// callback overrides the default `EmailProvider`-based sending.
30#[async_trait]
31pub trait SendVerificationEmail: Send + Sync {
32    async fn send(&self, user: &UserView, url: &str, token: &str) -> AuthResult<()>;
33}
34
35/// Shorthand for the async hook closure type used by
36/// [`EmailVerificationConfig::before_email_verification`] and
37/// [`EmailVerificationConfig::after_email_verification`].
38pub type EmailVerificationHook =
39    Arc<dyn Fn(&UserView) -> Pin<Box<dyn Future<Output = AuthResult<()>> + Send>> + Send + Sync>;
40
41/// Email verification plugin for handling email verification flows
42pub struct EmailVerificationPlugin {
43    config: EmailVerificationConfig,
44}
45
46#[derive(better_auth_core::PluginConfig)]
47#[plugin(name = "EmailVerificationPlugin")]
48pub struct EmailVerificationConfig {
49    /// How long a verification token stays valid. Default: 24 hours.
50    #[config(default = Duration::hours(24))]
51    pub verification_token_expiry: Duration,
52    /// Whether to send email notifications (on sign-up). Default: true.
53    #[config(default = true)]
54    pub send_email_notifications: bool,
55    /// Whether email verification is required before sign-in. Default: false.
56    #[config(default = false)]
57    pub require_verification_for_signin: bool,
58    /// Whether to auto-verify newly created users. Default: false.
59    #[config(default = false)]
60    pub auto_verify_new_users: bool,
61    /// When true, automatically send a verification email on sign-in if the
62    /// user is unverified. Default: false.
63    #[config(default = false)]
64    pub send_on_sign_in: bool,
65    /// When true, create a session after email verification and return the
66    /// session token in the verify-email response. Default: false.
67    #[config(default = false)]
68    pub auto_sign_in_after_verification: bool,
69    /// Optional custom email sender. When set this overrides the default
70    /// `EmailProvider`-based sending.
71    #[config(default = None, skip)]
72    pub send_verification_email: Option<Arc<dyn SendVerificationEmail>>,
73    /// Hook invoked **before** email verification (before updating the user).
74    #[config(default = None)]
75    pub before_email_verification: Option<EmailVerificationHook>,
76    /// Hook invoked **after** email verification (after the user has been updated).
77    #[config(default = None)]
78    pub after_email_verification: Option<EmailVerificationHook>,
79}
80
81impl EmailVerificationPlugin {
82    pub fn custom_send_verification_email(
83        mut self,
84        sender: Arc<dyn SendVerificationEmail>,
85    ) -> Self {
86        self.config.send_verification_email = Some(sender);
87        self
88    }
89}
90
91better_auth_core::impl_auth_plugin! {
92    EmailVerificationPlugin, "email-verification";
93    routes {
94        post "/send-verification-email" => handle_send_verification_email, "send_verification_email";
95        get "/verify-email" => handle_verify_email, "verify_email";
96    }
97    extra {
98        async fn on_user_created(&self, user: &S::User, ctx: &AuthContext<S>) -> AuthResult<()> {
99            // Send verification email for new users if configured.
100            // Also fire when a custom sender is set, even if send_email_notifications is false.
101            if (self.config.send_email_notifications || self.config.send_verification_email.is_some())
102                && !user.email_verified()
103                && let Some(email) = user.email()
104                && let Err(e) = self
105                    .send_verification_email_for_user(user, email, None, ctx)
106                    .await
107            {
108                tracing::warn!(
109                    email = %email,
110                    error = %e,
111                    "Failed to send verification email"
112                );
113            }
114            Ok(())
115        }
116    }
117}
118
119// ---------------------------------------------------------------------------
120// Route handlers (delegate to core functions)
121// ---------------------------------------------------------------------------
122
123impl EmailVerificationPlugin {
124    async fn handle_send_verification_email(
125        &self,
126        req: &AuthRequest,
127        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
128    ) -> AuthResult<AuthResponse> {
129        let body: SendVerificationEmailRequest = match better_auth_core::validate_request_body(req)
130        {
131            Ok(v) => v,
132            Err(resp) => return Ok(resp),
133        };
134        let current_user = ctx.require_session(req).await.ok().map(|(user, _)| user);
135        let response =
136            send_verification_email_core(&body, current_user.as_ref(), &self.config, ctx).await?;
137        Ok(AuthResponse::json(200, &response)?)
138    }
139
140    async fn handle_verify_email(
141        &self,
142        req: &AuthRequest,
143        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
144    ) -> AuthResult<AuthResponse> {
145        let token = req
146            .query
147            .get("token")
148            .ok_or_else(|| AuthError::bad_request("Verification token is required"))?;
149        let callback_url = req.query.get("callbackURL").cloned();
150
151        // Validate callbackURL against trusted origins, matching the TS
152        // `originCheck` middleware applied to the verify-email endpoint.
153        if let Some(ref url) = callback_url
154            && !ctx.config.advanced.disable_origin_check
155            && !ctx.config.is_redirect_target_trusted(url)
156        {
157            return Ok(AuthError::forbidden("Invalid callbackURL").to_auth_response());
158        }
159
160        let query = VerifyEmailQuery {
161            token: token.clone(),
162            callback_url,
163        };
164
165        let ip_address = req.headers.get("x-forwarded-for").cloned();
166        let user_agent = req.headers.get("user-agent").cloned();
167        let current_session = ctx.require_session(req).await.ok();
168
169        match verify_email_core(
170            &query,
171            current_session,
172            &self.config,
173            ip_address,
174            user_agent,
175            ctx,
176        )
177        .await?
178        {
179            VerifyEmailResult::Redirect { url, session_token } => {
180                let mut headers = better_auth_core::Headers::new();
181                _ = headers.insert("Location".to_string(), url);
182                _ = headers.insert("content-type".to_string(), "application/json".to_string());
183                if let Some(token) = session_token {
184                    let cookie = create_session_cookie(&token, &ctx.config);
185                    headers.append("Set-Cookie".to_string(), cookie);
186                }
187                Ok(AuthResponse {
188                    status: 302,
189                    headers,
190                    body: Vec::new(),
191                })
192            }
193            VerifyEmailResult::Json {
194                body,
195                session_token,
196            } => {
197                let mut response = AuthResponse::json(200, &body)?;
198                if let Some(token) = session_token {
199                    let cookie = create_session_cookie(&token, &ctx.config);
200                    response = response.with_header("Set-Cookie", cookie);
201                }
202                Ok(response)
203            }
204        }
205    }
206
207    /// Send a verification email for a specific user.
208    ///
209    /// If [`EmailVerificationConfig::send_verification_email`] is set the
210    /// custom callback is used; otherwise the default `EmailProvider` path is
211    /// taken.
212    async fn send_verification_email_for_user(
213        &self,
214        user: &impl AuthUser,
215        email: &str,
216        callback_url: Option<&str>,
217        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
218    ) -> AuthResult<()> {
219        let verification_token = token::create_email_verification_token(
220            &ctx.config.secret,
221            email,
222            None,
223            self.config.verification_token_expiry,
224            None,
225        )?;
226        let callback_url = callback_url.unwrap_or("/");
227        let verification_url = format!(
228            "{}/verify-email?token={}&callbackURL={}",
229            ctx.config.base_url,
230            verification_token,
231            urlencoding::encode(callback_url),
232        );
233
234        // Use custom sender if configured, otherwise fall back to EmailProvider
235        if let Some(ref custom_sender) = self.config.send_verification_email {
236            let user = UserView::from(user);
237            custom_sender
238                .send(&user, &verification_url, &verification_token)
239                .await?;
240        } else if self.config.send_email_notifications {
241            // Gracefully skip if no email provider is configured
242            if ctx.email_provider.is_some() {
243                let subject = "Verify your email address";
244                let html = format!(
245                    "<p>Click the link below to verify your email address:</p>\
246                     <p><a href=\"{url}\">Verify Email</a></p>",
247                    url = verification_url
248                );
249                let text = format!("Verify your email address: {}", verification_url);
250
251                ctx.email_provider()?
252                    .send(email, subject, &html, &text)
253                    .await?;
254            } else {
255                tracing::warn!(
256                    email = %email,
257                    "No email provider configured, skipping verification email"
258                );
259            }
260        }
261
262        Ok(())
263    }
264
265    /// Send a verification email on sign-in for an unverified user.
266    ///
267    /// Callers (e.g. the sign-in plugin) should invoke this when
268    /// [`EmailVerificationConfig::send_on_sign_in`] is `true` and the user is
269    /// not yet verified.
270    pub async fn send_verification_on_sign_in(
271        &self,
272        user: &impl AuthUser,
273        callback_url: Option<&str>,
274        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
275    ) -> AuthResult<()> {
276        if !self.config.send_on_sign_in {
277            return Ok(());
278        }
279
280        if user.email_verified() {
281            return Ok(());
282        }
283
284        if let Some(email) = user.email() {
285            self.send_verification_email_for_user(user, email, callback_url, ctx)
286                .await?;
287        }
288
289        Ok(())
290    }
291
292    /// Check if `send_on_sign_in` is enabled.
293    pub fn should_send_on_sign_in(&self) -> bool {
294        self.config.send_on_sign_in
295    }
296
297    /// Check if email verification is required for signin
298    pub fn is_verification_required(&self) -> bool {
299        self.config.require_verification_for_signin
300    }
301
302    /// Check if user is verified or verification is not required
303    pub fn is_user_verified_or_not_required(&self, user: &impl AuthUser) -> bool {
304        user.email_verified() || !self.config.require_verification_for_signin
305    }
306}
307
308// ---------------------------------------------------------------------------
309// Axum plugin
310// ---------------------------------------------------------------------------