Skip to main content

better_auth_api/plugins/
email_verification.rs

1use async_trait::async_trait;
2use chrono::{Duration, Utc};
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5use validator::Validate;
6
7use better_auth_core::{AuthContext, AuthPlugin, AuthRoute};
8use better_auth_core::{AuthError, AuthResult};
9use better_auth_core::{AuthRequest, AuthResponse, CreateVerification, HttpMethod, UpdateUser};
10use better_auth_core::{AuthUser, AuthVerification, DatabaseAdapter};
11
12/// Email verification plugin for handling email verification flows
13pub struct EmailVerificationPlugin {
14    config: EmailVerificationConfig,
15}
16
17#[derive(Debug, Clone)]
18pub struct EmailVerificationConfig {
19    pub verification_token_expiry_hours: i64,
20    pub send_email_notifications: bool,
21    pub require_verification_for_signin: bool,
22    pub auto_verify_new_users: bool,
23}
24
25// Request structures for email verification endpoints
26#[derive(Debug, Deserialize, Validate)]
27struct SendVerificationEmailRequest {
28    #[validate(email(message = "Invalid email address"))]
29    email: String,
30    #[serde(rename = "callbackURL")]
31    callback_url: Option<String>,
32}
33
34// Response structures
35#[derive(Debug, Serialize)]
36struct StatusResponse {
37    status: bool,
38    description: Option<String>,
39}
40
41#[derive(Debug, Serialize)]
42struct VerifyEmailResponse<U: Serialize> {
43    user: U,
44    status: bool,
45}
46
47impl EmailVerificationPlugin {
48    pub fn new() -> Self {
49        Self {
50            config: EmailVerificationConfig::default(),
51        }
52    }
53
54    pub fn with_config(config: EmailVerificationConfig) -> Self {
55        Self { config }
56    }
57
58    pub fn verification_token_expiry_hours(mut self, hours: i64) -> Self {
59        self.config.verification_token_expiry_hours = hours;
60        self
61    }
62
63    pub fn send_email_notifications(mut self, send: bool) -> Self {
64        self.config.send_email_notifications = send;
65        self
66    }
67
68    pub fn require_verification_for_signin(mut self, require: bool) -> Self {
69        self.config.require_verification_for_signin = require;
70        self
71    }
72
73    pub fn auto_verify_new_users(mut self, auto_verify: bool) -> Self {
74        self.config.auto_verify_new_users = auto_verify;
75        self
76    }
77}
78
79impl Default for EmailVerificationPlugin {
80    fn default() -> Self {
81        Self::new()
82    }
83}
84
85impl Default for EmailVerificationConfig {
86    fn default() -> Self {
87        Self {
88            verification_token_expiry_hours: 24, // 24 hours default expiry
89            send_email_notifications: true,
90            require_verification_for_signin: false,
91            auto_verify_new_users: false,
92        }
93    }
94}
95
96#[async_trait]
97impl<DB: DatabaseAdapter> AuthPlugin<DB> for EmailVerificationPlugin {
98    fn name(&self) -> &'static str {
99        "email-verification"
100    }
101
102    fn routes(&self) -> Vec<AuthRoute> {
103        vec![
104            AuthRoute::post("/send-verification-email", "send_verification_email"),
105            AuthRoute::get("/verify-email", "verify_email"),
106        ]
107    }
108
109    async fn on_request(
110        &self,
111        req: &AuthRequest,
112        ctx: &AuthContext<DB>,
113    ) -> AuthResult<Option<AuthResponse>> {
114        match (req.method(), req.path()) {
115            (HttpMethod::Post, "/send-verification-email") => {
116                Ok(Some(self.handle_send_verification_email(req, ctx).await?))
117            }
118            (HttpMethod::Get, "/verify-email") => {
119                Ok(Some(self.handle_verify_email(req, ctx).await?))
120            }
121            _ => Ok(None),
122        }
123    }
124
125    async fn on_user_created(&self, user: &DB::User, ctx: &AuthContext<DB>) -> AuthResult<()> {
126        // Send verification email for new users if configured
127        if self.config.send_email_notifications
128            && !user.email_verified()
129            && let Some(email) = user.email()
130        {
131            // Gracefully skip if no email provider is configured
132            if ctx.email_provider.is_some() {
133                if let Err(e) = self
134                    .send_verification_email_internal(email, None, ctx)
135                    .await
136                {
137                    eprintln!(
138                        "[email-verification] Failed to send verification email to {}: {}",
139                        email, e
140                    );
141                }
142            } else {
143                eprintln!(
144                    "[email-verification] No email provider configured, skipping verification email for {}",
145                    email
146                );
147            }
148        }
149        Ok(())
150    }
151}
152
153// Implementation methods outside the trait
154impl EmailVerificationPlugin {
155    async fn handle_send_verification_email<DB: DatabaseAdapter>(
156        &self,
157        req: &AuthRequest,
158        ctx: &AuthContext<DB>,
159    ) -> AuthResult<AuthResponse> {
160        let send_req: SendVerificationEmailRequest =
161            match better_auth_core::validate_request_body(req) {
162                Ok(v) => v,
163                Err(resp) => return Ok(resp),
164            };
165
166        // Check if user exists
167        let user = ctx
168            .database
169            .get_user_by_email(&send_req.email)
170            .await?
171            .ok_or_else(|| AuthError::not_found("No user found with this email address"))?;
172
173        // Check if user is already verified
174        if user.email_verified() {
175            return Err(AuthError::bad_request("Email is already verified"));
176        }
177
178        // Send verification email
179        self.send_verification_email_internal(
180            &send_req.email,
181            send_req.callback_url.as_deref(),
182            ctx,
183        )
184        .await?;
185
186        let response = StatusResponse {
187            status: true,
188            description: Some("Verification email sent successfully".to_string()),
189        };
190        Ok(AuthResponse::json(200, &response)?)
191    }
192
193    async fn handle_verify_email<DB: DatabaseAdapter>(
194        &self,
195        req: &AuthRequest,
196        ctx: &AuthContext<DB>,
197    ) -> AuthResult<AuthResponse> {
198        // Extract token from query parameters
199        let token = req
200            .query
201            .get("token")
202            .ok_or_else(|| AuthError::bad_request("Verification token is required"))?;
203
204        let callback_url = req.query.get("callbackURL");
205
206        // Find verification token
207        let verification = ctx
208            .database
209            .get_verification_by_value(token)
210            .await?
211            .ok_or_else(|| AuthError::bad_request("Invalid or expired verification token"))?;
212
213        // Get user by email (stored in identifier field)
214        let user = ctx
215            .database
216            .get_user_by_email(verification.identifier())
217            .await?
218            .ok_or_else(|| AuthError::not_found("User associated with this token not found"))?;
219
220        // Check if already verified
221        if user.email_verified() {
222            let response = VerifyEmailResponse { user, status: true };
223            return Ok(AuthResponse::json(200, &response)?);
224        }
225
226        // Update user email verification status
227        let update_user = UpdateUser {
228            email: None,
229            name: None,
230            image: None,
231            email_verified: Some(true),
232            username: None,
233            display_username: None,
234            role: None,
235            banned: None,
236            ban_reason: None,
237            ban_expires: None,
238            two_factor_enabled: None,
239            metadata: None,
240        };
241
242        let updated_user = ctx.database.update_user(user.id(), update_user).await?;
243
244        // Delete the used verification token
245        ctx.database.delete_verification(verification.id()).await?;
246
247        // If callback URL is provided, redirect
248        if let Some(callback_url) = callback_url {
249            let redirect_url = format!("{}?verified=true", callback_url);
250            let mut headers = std::collections::HashMap::new();
251            headers.insert("Location".to_string(), redirect_url);
252            return Ok(AuthResponse {
253                status: 302,
254                headers,
255                body: Vec::new(),
256            });
257        }
258
259        let response = VerifyEmailResponse {
260            user: updated_user,
261            status: true,
262        };
263        Ok(AuthResponse::json(200, &response)?)
264    }
265
266    async fn send_verification_email_internal<DB: DatabaseAdapter>(
267        &self,
268        email: &str,
269        callback_url: Option<&str>,
270        ctx: &AuthContext<DB>,
271    ) -> AuthResult<()> {
272        // Generate verification token
273        let verification_token = format!("verify_{}", Uuid::new_v4());
274        let expires_at = Utc::now() + Duration::hours(self.config.verification_token_expiry_hours);
275
276        // Create verification token
277        let create_verification = CreateVerification {
278            identifier: email.to_string(),
279            value: verification_token.clone(),
280            expires_at,
281        };
282
283        ctx.database
284            .create_verification(create_verification)
285            .await?;
286
287        // Send email via the configured provider
288        if self.config.send_email_notifications {
289            let verification_url = if let Some(callback_url) = callback_url {
290                format!("{}?token={}", callback_url, verification_token)
291            } else {
292                format!(
293                    "{}/verify-email?token={}",
294                    ctx.config.base_url, verification_token
295                )
296            };
297
298            let subject = "Verify your email address";
299            let html = format!(
300                "<p>Click the link below to verify your email address:</p>\
301                 <p><a href=\"{url}\">Verify Email</a></p>",
302                url = verification_url
303            );
304            let text = format!("Verify your email address: {}", verification_url);
305
306            ctx.email_provider()?
307                .send(email, subject, &html, &text)
308                .await?;
309        }
310
311        Ok(())
312    }
313
314    /// Check if email verification is required for signin
315    pub fn is_verification_required(&self) -> bool {
316        self.config.require_verification_for_signin
317    }
318
319    /// Check if user is verified or verification is not required
320    pub async fn is_user_verified_or_not_required(&self, user: &impl AuthUser) -> bool {
321        user.email_verified() || !self.config.require_verification_for_signin
322    }
323}