1use crate::prelude::*;
2use rand::distr::SampleString;
3use serde::{Deserialize, Serialize};
4use sqlx::{Row, postgres::PgRow};
5use std::{collections::BTreeMap, sync::LazyLock};
6
7pub const TOKEN_VALIDITY_HOURS: i32 = 24;
8pub const RESEND_COOLDOWN_SECONDS: f64 = 60.0;
9
10#[derive(Serialize, Deserialize)]
11pub struct UserEmailVerification {
12 pub uuid: uuid::Uuid,
13 pub user: super::user::User,
14
15 pub email: compact_str::CompactString,
17 pub token: String,
18
19 pub created: chrono::NaiveDateTime,
20
21 extension_data: super::ModelExtensionData,
22}
23
24impl BaseModel for UserEmailVerification {
25 const NAME: &'static str = "user_email_verification";
26
27 fn get_extension_list() -> &'static super::ModelExtensionList {
28 static EXTENSIONS: LazyLock<super::ModelExtensionList> =
29 LazyLock::new(|| parking_lot::RwLock::new(Vec::new()));
30
31 &EXTENSIONS
32 }
33
34 fn get_extension_data(&self) -> &super::ModelExtensionData {
35 &self.extension_data
36 }
37
38 #[inline]
39 fn base_columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
40 let prefix = prefix.unwrap_or_default();
41
42 let mut columns = BTreeMap::from([
43 (
44 "user_email_verifications.uuid",
45 compact_str::format_compact!("{prefix}uuid"),
46 ),
47 (
48 "user_email_verifications.email",
49 compact_str::format_compact!("{prefix}email"),
50 ),
51 (
52 "user_email_verifications.token",
53 compact_str::format_compact!("{prefix}token"),
54 ),
55 (
56 "user_email_verifications.created",
57 compact_str::format_compact!("{prefix}created"),
58 ),
59 ]);
60
61 columns.extend(super::user::User::base_columns(Some("user_")));
62
63 columns
64 }
65
66 #[inline]
67 fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
68 let prefix = prefix.unwrap_or_default();
69
70 Ok(Self {
71 uuid: row.try_get(compact_str::format_compact!("{prefix}uuid").as_str())?,
72 user: super::user::User::map(Some("user_"), row)?,
73 email: row.try_get(compact_str::format_compact!("{prefix}email").as_str())?,
74 token: row.try_get(compact_str::format_compact!("{prefix}token").as_str())?,
75 created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
76 extension_data: Self::map_extensions(prefix, row)?,
77 })
78 }
79}
80
81impl UserEmailVerification {
82 pub async fn create(
83 database: &crate::database::Database,
84 user_uuid: uuid::Uuid,
85 email: &str,
86 ) -> Result<String, anyhow::Error> {
87 let mut transaction = database.write().begin().await?;
88 let token = Self::create_with_transaction(&mut transaction, user_uuid, email).await?;
89 transaction.commit().await?;
90
91 Ok(token)
92 }
93
94 pub async fn create_with_transaction(
97 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
98 user_uuid: uuid::Uuid,
99 email: &str,
100 ) -> Result<String, anyhow::Error> {
101 let existing = sqlx::query(
102 r#"
103 SELECT COUNT(*)
104 FROM user_email_verifications
105 WHERE user_email_verifications.user_uuid = $1
106 AND user_email_verifications.created > NOW() - make_interval(secs => $2)
107 "#,
108 )
109 .bind(user_uuid)
110 .bind(RESEND_COOLDOWN_SECONDS)
111 .fetch_optional(&mut **transaction)
112 .await?;
113
114 if let Some(row) = existing
115 && row.get::<i64, _>(0) > 0
116 {
117 return Err(anyhow::anyhow!(
118 "a verification email was already requested recently"
119 ));
120 }
121
122 sqlx::query(
123 r#"
124 DELETE FROM user_email_verifications
125 WHERE user_email_verifications.user_uuid = $1
126 "#,
127 )
128 .bind(user_uuid)
129 .execute(&mut **transaction)
130 .await?;
131
132 let token = rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 96);
133
134 sqlx::query(
135 r#"
136 INSERT INTO user_email_verifications (user_uuid, email, token_start, token, created)
137 VALUES ($1, $2, $3, crypt($4, gen_salt('bf', 12)), NOW())
138 "#,
139 )
140 .bind(user_uuid)
141 .bind(email)
142 .bind(&token[0..16])
143 .bind(&token)
144 .execute(&mut **transaction)
145 .await?;
146
147 Ok(token)
148 }
149
150 pub async fn pending_email_by_user_uuid(
151 database: &crate::database::Database,
152 user_uuid: uuid::Uuid,
153 ) -> Result<Option<compact_str::CompactString>, crate::database::DatabaseError> {
154 let row = sqlx::query(
155 r#"
156 SELECT user_email_verifications.email
157 FROM user_email_verifications
158 WHERE user_email_verifications.user_uuid = $1
159 AND user_email_verifications.created > NOW() - make_interval(hours => $2)
160 ORDER BY user_email_verifications.created DESC
161 LIMIT 1
162 "#,
163 )
164 .bind(user_uuid)
165 .bind(TOKEN_VALIDITY_HOURS)
166 .fetch_optional(database.read())
167 .await?;
168
169 let row = match row {
170 Some(row) => row,
171 None => return Ok(None),
172 };
173
174 Ok(Some(row.try_get("email")?))
175 }
176
177 pub async fn send(
178 state: &crate::State,
179 user: &super::user::User,
180 email: &str,
181 token: &str,
182 ) -> Result<(), anyhow::Error> {
183 let verification_link = {
184 let settings = state.settings.get().await?;
185
186 format!(
187 "{}/auth/verify-email?token={}",
188 settings.app.url.trim_end_matches('/'),
189 urlencoding::encode(token),
190 )
191 };
192
193 state
194 .mail
195 .send_template_foreground(
196 state,
197 "email_verification",
198 email.into(),
199 minijinja::context! {
200 user => user,
201 email => email,
202 verification_link => verification_link,
203 },
204 )
205 .await
206 }
207
208 pub async fn delete_by_token(
209 database: &crate::database::Database,
210 token: &str,
211 ) -> Result<Option<Self>, crate::database::DatabaseError> {
212 let Some(token_start) = token.get(0..16) else {
213 return Ok(None);
214 };
215
216 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
217 r#"
218 WITH user_email_verifications AS MATERIALIZED (
219 SELECT * FROM user_email_verifications
220 WHERE user_email_verifications.token_start = $1
221 AND user_email_verifications.created > NOW() - make_interval(hours => $3)
222 )
223 SELECT {}, {} FROM user_email_verifications
224 JOIN users ON users.uuid = user_email_verifications.user_uuid
225 LEFT JOIN roles ON roles.uuid = users.role_uuid
226 WHERE user_email_verifications.token = crypt($2, user_email_verifications.token)
227 "#,
228 Self::columns_sql(None),
229 super::user::User::columns_sql(Some("user_"))
230 )))
231 .bind(token_start)
232 .bind(token)
233 .bind(TOKEN_VALIDITY_HOURS)
234 .fetch_optional(database.read())
235 .await?;
236
237 let row = match row {
238 Some(row) => row,
239 None => return Ok(None),
240 };
241
242 sqlx::query(
243 r#"
244 DELETE FROM user_email_verifications
245 WHERE user_email_verifications.uuid = $1
246 "#,
247 )
248 .bind(row.get::<uuid::Uuid, _>("uuid"))
249 .execute(database.write())
250 .await?;
251
252 Ok(Some(Self::map(None, &row)?))
253 }
254
255 pub async fn delete_by_user_uuid(
256 database: &crate::database::Database,
257 user_uuid: uuid::Uuid,
258 ) -> Result<(), crate::database::DatabaseError> {
259 sqlx::query(
260 r#"
261 DELETE FROM user_email_verifications
262 WHERE user_email_verifications.user_uuid = $1
263 "#,
264 )
265 .bind(user_uuid)
266 .execute(database.write())
267 .await?;
268
269 Ok(())
270 }
271
272 pub async fn delete_expired(database: &crate::database::Database) -> Result<u64, sqlx::Error> {
273 Ok(sqlx::query(
274 r#"
275 DELETE FROM user_email_verifications
276 WHERE user_email_verifications.created < NOW() - make_interval(hours => $1)
277 "#,
278 )
279 .bind(TOKEN_VALIDITY_HOURS)
280 .execute(database.write())
281 .await?
282 .rows_affected())
283 }
284}