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