From 0405e952af50de5ebc58ab22df6f33c0ee41f44f Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Tue, 8 Jul 2025 18:01:20 +0200 Subject: [PATCH] Make email address lookups case-insensitive --- ...32594c97ba0afe972f0fee145b6094789fb6c7.json} | 4 ++-- ...2473215f4d3c549ea2c5a4f860a102cc46a667.json} | 4 ++-- ...250708155857_idx_user_emails_lower_email.sql | 11 +++++++++++ crates/storage-pg/src/user/email.rs | 17 ++++++++++------- crates/storage-pg/src/user/tests.rs | 10 +++++++--- crates/storage/src/user/email.rs | 6 ++++++ 6 files changed, 38 insertions(+), 14 deletions(-) rename crates/storage-pg/.sqlx/{query-f3b043b69e0554b5b4d8f5cf05960632fb2ebd38916dd2e9beac232c7e14c1ec.json => query-5eea2f4c3e82ae606b09b8a81332594c97ba0afe972f0fee145b6094789fb6c7.json} (85%) rename crates/storage-pg/.sqlx/{query-f7d26de1d380e3e52f47f2b89ed7506e1e4cca72682bc7737e6508dc4015b8d5.json => query-ca093cab5143bb3dded2eda9e82473215f4d3c549ea2c5a4f860a102cc46a667.json} (84%) create mode 100644 crates/storage-pg/migrations/20250708155857_idx_user_emails_lower_email.sql diff --git a/crates/storage-pg/.sqlx/query-f3b043b69e0554b5b4d8f5cf05960632fb2ebd38916dd2e9beac232c7e14c1ec.json b/crates/storage-pg/.sqlx/query-5eea2f4c3e82ae606b09b8a81332594c97ba0afe972f0fee145b6094789fb6c7.json similarity index 85% rename from crates/storage-pg/.sqlx/query-f3b043b69e0554b5b4d8f5cf05960632fb2ebd38916dd2e9beac232c7e14c1ec.json rename to crates/storage-pg/.sqlx/query-5eea2f4c3e82ae606b09b8a81332594c97ba0afe972f0fee145b6094789fb6c7.json index cc7f8d1af..3d81b9141 100644 --- a/crates/storage-pg/.sqlx/query-f3b043b69e0554b5b4d8f5cf05960632fb2ebd38916dd2e9beac232c7e14c1ec.json +++ b/crates/storage-pg/.sqlx/query-5eea2f4c3e82ae606b09b8a81332594c97ba0afe972f0fee145b6094789fb6c7.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT user_email_id\n , user_id\n , email\n , created_at\n FROM user_emails\n WHERE email = $1\n ", + "query": "\n SELECT user_email_id\n , user_id\n , email\n , created_at\n FROM user_emails\n WHERE LOWER(email) = LOWER($1)\n ", "describe": { "columns": [ { @@ -36,5 +36,5 @@ false ] }, - "hash": "f3b043b69e0554b5b4d8f5cf05960632fb2ebd38916dd2e9beac232c7e14c1ec" + "hash": "5eea2f4c3e82ae606b09b8a81332594c97ba0afe972f0fee145b6094789fb6c7" } diff --git a/crates/storage-pg/.sqlx/query-f7d26de1d380e3e52f47f2b89ed7506e1e4cca72682bc7737e6508dc4015b8d5.json b/crates/storage-pg/.sqlx/query-ca093cab5143bb3dded2eda9e82473215f4d3c549ea2c5a4f860a102cc46a667.json similarity index 84% rename from crates/storage-pg/.sqlx/query-f7d26de1d380e3e52f47f2b89ed7506e1e4cca72682bc7737e6508dc4015b8d5.json rename to crates/storage-pg/.sqlx/query-ca093cab5143bb3dded2eda9e82473215f4d3c549ea2c5a4f860a102cc46a667.json index 3e278f223..8ae291bb6 100644 --- a/crates/storage-pg/.sqlx/query-f7d26de1d380e3e52f47f2b89ed7506e1e4cca72682bc7737e6508dc4015b8d5.json +++ b/crates/storage-pg/.sqlx/query-ca093cab5143bb3dded2eda9e82473215f4d3c549ea2c5a4f860a102cc46a667.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT user_email_id\n , user_id\n , email\n , created_at\n FROM user_emails\n\n WHERE user_id = $1 AND email = $2\n ", + "query": "\n SELECT user_email_id\n , user_id\n , email\n , created_at\n FROM user_emails\n\n WHERE user_id = $1 AND LOWER(email) = LOWER($2)\n ", "describe": { "columns": [ { @@ -37,5 +37,5 @@ false ] }, - "hash": "f7d26de1d380e3e52f47f2b89ed7506e1e4cca72682bc7737e6508dc4015b8d5" + "hash": "ca093cab5143bb3dded2eda9e82473215f4d3c549ea2c5a4f860a102cc46a667" } diff --git a/crates/storage-pg/migrations/20250708155857_idx_user_emails_lower_email.sql b/crates/storage-pg/migrations/20250708155857_idx_user_emails_lower_email.sql new file mode 100644 index 000000000..06b3dde6a --- /dev/null +++ b/crates/storage-pg/migrations/20250708155857_idx_user_emails_lower_email.sql @@ -0,0 +1,11 @@ +-- no-transaction +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +-- Please see LICENSE in the repository root for full details. + +-- When we're looking up an email address, we want to be able to do a case-insensitive +-- lookup, so we index the email address lowercase and request it like that +CREATE INDEX CONCURRENTLY + user_emails_lower_email_idx + ON user_emails (LOWER(email)); diff --git a/crates/storage-pg/src/user/email.rs b/crates/storage-pg/src/user/email.rs index 0ab19ee73..ba7f1a35f 100644 --- a/crates/storage-pg/src/user/email.rs +++ b/crates/storage-pg/src/user/email.rs @@ -15,7 +15,7 @@ use mas_storage::{ user::{UserEmailFilter, UserEmailRepository}, }; use rand::RngCore; -use sea_query::{Expr, PostgresQueryBuilder, Query, enum_def}; +use sea_query::{Expr, Func, PostgresQueryBuilder, Query, SimpleExpr, enum_def}; use sea_query_binder::SqlxBinder; use sqlx::PgConnection; use ulid::Ulid; @@ -110,10 +110,13 @@ impl Filter for UserEmailFilter<'_> { .add_option(self.user().map(|user| { Expr::col((UserEmails::Table, UserEmails::UserId)).eq(Uuid::from(user.id)) })) - .add_option( - self.email() - .map(|email| Expr::col((UserEmails::Table, UserEmails::Email)).eq(email)), - ) + .add_option(self.email().map(|email| { + SimpleExpr::from(Func::lower(Expr::col(( + UserEmails::Table, + UserEmails::Email, + )))) + .eq(Func::lower(email)) + })) } } @@ -175,7 +178,7 @@ impl UserEmailRepository for PgUserEmailRepository<'_> { , created_at FROM user_emails - WHERE user_id = $1 AND email = $2 + WHERE user_id = $1 AND LOWER(email) = LOWER($2) "#, Uuid::from(user.id), email, @@ -209,7 +212,7 @@ impl UserEmailRepository for PgUserEmailRepository<'_> { , email , created_at FROM user_emails - WHERE email = $1 + WHERE LOWER(email) = LOWER($1) "#, email, ) diff --git a/crates/storage-pg/src/user/tests.rs b/crates/storage-pg/src/user/tests.rs index 898a30b65..3225c8bbd 100644 --- a/crates/storage-pg/src/user/tests.rs +++ b/crates/storage-pg/src/user/tests.rs @@ -268,6 +268,10 @@ async fn test_user_repo_find_by_username(pool: PgPool) { async fn test_user_email_repo(pool: PgPool) { const USERNAME: &str = "john"; const EMAIL: &str = "john@example.com"; + // This is what is stored in the database, making sure that: + // 1. we don't normalize the email address when storing it + // 2. looking it up is case-incensitive + const UPPERCASE_EMAIL: &str = "JOHN@EXAMPLE.COM"; let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed(); let mut rng = ChaChaRng::seed_from_u64(42); @@ -295,12 +299,12 @@ async fn test_user_email_repo(pool: PgPool) { let user_email = repo .user_email() - .add(&mut rng, &clock, &user, EMAIL.to_owned()) + .add(&mut rng, &clock, &user, UPPERCASE_EMAIL.to_owned()) .await .unwrap(); assert_eq!(user_email.user_id, user.id); - assert_eq!(user_email.email, EMAIL); + assert_eq!(user_email.email, UPPERCASE_EMAIL); // Check the counts assert_eq!(repo.user_email().count(all).await.unwrap(), 1); @@ -321,7 +325,7 @@ async fn test_user_email_repo(pool: PgPool) { .expect("user email was not found"); assert_eq!(user_email.user_id, user.id); - assert_eq!(user_email.email, EMAIL); + assert_eq!(user_email.email, UPPERCASE_EMAIL); // Listing the user emails should work let emails = repo diff --git a/crates/storage/src/user/email.rs b/crates/storage/src/user/email.rs index 1da14d9de..daaead638 100644 --- a/crates/storage/src/user/email.rs +++ b/crates/storage/src/user/email.rs @@ -36,6 +36,8 @@ impl<'a> UserEmailFilter<'a> { } /// Filter for emails matching a specific email address + /// + /// The email address is case-insensitive #[must_use] pub fn for_email(mut self, email: &'a str) -> Self { self.email = Some(email); @@ -81,6 +83,8 @@ pub trait UserEmailRepository: Send + Sync { /// Lookup an [`UserEmail`] by its email address for a [`User`] /// + /// The email address is case-insensitive + /// /// Returns `None` if no matching [`UserEmail`] was found /// /// # Parameters @@ -95,6 +99,8 @@ pub trait UserEmailRepository: Send + Sync { /// Lookup an [`UserEmail`] by its email address /// + /// The email address is case-insensitive + /// /// Returns `None` if no matching [`UserEmail`] was found or if multiple /// [`UserEmail`] are found ///