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