Skip to main content

shared/models/
user_email_verification.rs

1use crate::{mail::MailRecipient, 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    /// For an email change this is the pending address, not yet written to the user.
16    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    /// Replaces any outstanding verification, so an older link cannot resurrect an abandoned
95    /// address.
96    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, user_email_verifications.uuid
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        request_host: Option<&str>,
180        user: &super::user::User,
181        email: &str,
182        token: &str,
183    ) -> Result<(), anyhow::Error> {
184        let verification_link = {
185            let settings = state.settings.get().await?;
186
187            format!(
188                "{}/auth/verify-email?token={}",
189                settings
190                    .app
191                    .url_for_host(request_host)
192                    .trim_end_matches('/'),
193                urlencoding::encode(token),
194            )
195        };
196
197        state
198            .mail
199            .send_template_foreground(
200                state,
201                "email_verification",
202                MailRecipient::new(email, user.language.clone()),
203                minijinja::context! {
204                    user => minijinja::value::Serde(&user),
205                    email => email,
206                    verification_link => verification_link,
207                },
208            )
209            .await
210    }
211
212    pub async fn delete_by_token(
213        database: &crate::database::Database,
214        token: &str,
215    ) -> Result<Option<Self>, crate::database::DatabaseError> {
216        let Some(token_start) = token.get(0..16) else {
217            return Ok(None);
218        };
219
220        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
221            r#"
222            WITH user_email_verifications AS MATERIALIZED (
223                SELECT * FROM user_email_verifications
224                WHERE user_email_verifications.token_start = $1
225                AND user_email_verifications.created > NOW() - make_interval(hours => $3)
226            )
227            SELECT {}, {} FROM user_email_verifications
228            JOIN users ON users.uuid = user_email_verifications.user_uuid
229            LEFT JOIN roles ON roles.uuid = users.role_uuid
230            WHERE user_email_verifications.token = crypt($2, user_email_verifications.token)
231            "#,
232            Self::columns_sql(None),
233            super::user::User::columns_sql(Some("user_"))
234        )))
235        .bind(token_start)
236        .bind(token)
237        .bind(TOKEN_VALIDITY_HOURS)
238        .fetch_optional(database.read())
239        .await?;
240
241        let row = match row {
242            Some(row) => row,
243            None => return Ok(None),
244        };
245
246        sqlx::query(
247            r#"
248            DELETE FROM user_email_verifications
249            WHERE user_email_verifications.uuid = $1
250            "#,
251        )
252        .bind(row.get::<uuid::Uuid, _>("uuid"))
253        .execute(database.write())
254        .await?;
255
256        Ok(Some(Self::map(None, &row)?))
257    }
258
259    pub async fn delete_by_user_uuid(
260        database: &crate::database::Database,
261        user_uuid: uuid::Uuid,
262    ) -> Result<(), crate::database::DatabaseError> {
263        sqlx::query(
264            r#"
265            DELETE FROM user_email_verifications
266            WHERE user_email_verifications.user_uuid = $1
267            "#,
268        )
269        .bind(user_uuid)
270        .execute(database.write())
271        .await?;
272
273        Ok(())
274    }
275
276    pub async fn delete_expired(database: &crate::database::Database) -> Result<u64, sqlx::Error> {
277        Ok(sqlx::query(
278            r#"
279            DELETE FROM user_email_verifications
280            WHERE user_email_verifications.created < NOW() - make_interval(hours => $1)
281            "#,
282        )
283        .bind(TOKEN_VALIDITY_HOURS)
284        .execute(database.write())
285        .await?
286        .rows_affected())
287    }
288}