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 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, 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 if self.config.send_email_notifications
128 && !user.email_verified()
129 && let Some(email) = user.email()
130 {
131 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
153impl 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 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 if user.email_verified() {
175 return Err(AuthError::bad_request("Email is already verified"));
176 }
177
178 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 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 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 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 if user.email_verified() {
222 let response = VerifyEmailResponse { user, status: true };
223 return Ok(AuthResponse::json(200, &response)?);
224 }
225
226 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 ctx.database.delete_verification(verification.id()).await?;
246
247 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 let verification_token = format!("verify_{}", Uuid::new_v4());
274 let expires_at = Utc::now() + Duration::hours(self.config.verification_token_expiry_hours);
275
276 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 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 pub fn is_verification_required(&self) -> bool {
316 self.config.require_verification_for_signin
317 }
318
319 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}