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