max / makenotwork
- Co-Authored-By
- Claude Opus 4.7 (1M context) <noreply@anthropic.com>
19 files changed,
+182 insertions,
-61 deletions
| @@ -86,7 +86,7 @@ | |||
| 86 | 86 | Self { | |
| 87 | 87 | id: user.id, | |
| 88 | 88 | username: user.username, | |
| 89 | - | email: user.email, | |
| 89 | + | email: user.email.into_inner(), | |
| 90 | 90 | display_name: user.display_name, | |
| 91 | 91 | can_create_projects: user.can_create_projects, | |
| 92 | 92 | suspended, |
| @@ -4,7 +4,7 @@ | |||
| 4 | 4 | ||
| 5 | 5 | use super::enums::AppealDecision; | |
| 6 | 6 | use super::models::*; | |
| 7 | - | use super::validated_types::Username; | |
| 7 | + | use super::validated_types::{Email, Username}; | |
| 8 | 8 | use super::UserId; | |
| 9 | 9 | use crate::error::Result; | |
| 10 | 10 | ||
| @@ -13,7 +13,7 @@ | |||
| 13 | 13 | pub async fn create_user( | |
| 14 | 14 | pool: &PgPool, | |
| 15 | 15 | username: &Username, | |
| 16 | - | email: &str, | |
| 16 | + | email: &Email, | |
| 17 | 17 | password_hash: &str, | |
| 18 | 18 | ) -> Result<DbUser> { | |
| 19 | 19 | let user = sqlx::query_as::<_, DbUser>( | |
| @@ -66,7 +66,7 @@ | |||
| 66 | 66 | ||
| 67 | 67 | /// Fetch a user by email address. Returns `None` if not found. | |
| 68 | 68 | #[tracing::instrument(skip_all)] | |
| 69 | - | pub async fn get_user_by_email(pool: &PgPool, email: &str) -> Result<Option<DbUser>> { | |
| 69 | + | pub async fn get_user_by_email(pool: &PgPool, email: &Email) -> Result<Option<DbUser>> { | |
| 70 | 70 | let user = sqlx::query_as::<_, DbUser>("SELECT * FROM users WHERE email = $1") | |
| 71 | 71 | .bind(email) | |
| 72 | 72 | .fetch_optional(pool) | |
| @@ -240,7 +240,7 @@ | |||
| 240 | 240 | pub async fn create_sandbox_user( | |
| 241 | 241 | pool: &PgPool, | |
| 242 | 242 | username: &Username, | |
| 243 | - | email: &str, | |
| 243 | + | email: &Email, | |
| 244 | 244 | password_hash: &str, | |
| 245 | 245 | expiry_secs: i64, | |
| 246 | 246 | ) -> Result<DbUser> { | |
| @@ -771,7 +771,7 @@ | |||
| 771 | 771 | #[derive(sqlx::FromRow)] | |
| 772 | 772 | pub struct StatusAlertSubscriber { | |
| 773 | 773 | pub id: UserId, | |
| 774 | - | pub email: String, | |
| 774 | + | pub email: Email, | |
| 775 | 775 | pub display_name: Option<String>, | |
| 776 | 776 | } | |
| 777 | 777 | ||
| @@ -961,7 +961,7 @@ | |||
| 961 | 961 | /// Look up a verified user by email (case-insensitive). | |
| 962 | 962 | /// Returns the user ID if a verified account exists with that email. | |
| 963 | 963 | #[tracing::instrument(skip_all)] | |
| 964 | - | pub async fn get_verified_user_id_by_email(pool: &PgPool, email: &str) -> Result<Option<UserId>> { | |
| 964 | + | pub async fn get_verified_user_id_by_email(pool: &PgPool, email: &Email) -> Result<Option<UserId>> { | |
| 965 | 965 | let id = sqlx::query_scalar( | |
| 966 | 966 | "SELECT id FROM users WHERE LOWER(email) = LOWER($1) AND email_verified = true", | |
| 967 | 967 | ) |
| @@ -121,6 +121,112 @@ | |||
| 121 | 121 | Username => crate::validation::validate_username, | |
| 122 | 122 | ); | |
| 123 | 123 | ||
| 124 | + | // ── Email ── | |
| 125 | + | ||
| 126 | + | /// Validated, normalized email address. | |
| 127 | + | /// | |
| 128 | + | /// `Email::new()` trims, lowercases, and validates via RFC 5321 length cap | |
| 129 | + | /// and the `email_address` crate's syntax check. Stored as the normalized | |
| 130 | + | /// form, so equality and DB lookups are case-insensitive by construction. | |
| 131 | + | /// | |
| 132 | + | /// DB reads use `from_trusted()` to skip re-validation — the DB is the | |
| 133 | + | /// source of truth and all writes go through `new()`. | |
| 134 | + | #[derive(Clone, Debug, PartialEq, Eq, Hash)] | |
| 135 | + | pub struct Email(String); | |
| 136 | + | ||
| 137 | + | /// RFC 5321 total length cap. Addresses longer than this won't survive any | |
| 138 | + | /// real mail transport. | |
| 139 | + | const EMAIL_MAX_LEN: usize = 254; | |
| 140 | + | ||
| 141 | + | impl Email { | |
| 142 | + | /// Normalize (trim + lowercase) and validate. Returns `Err(AppError::Validation)` | |
| 143 | + | /// for invalid syntax or over-length input. | |
| 144 | + | pub fn new(s: &str) -> std::result::Result<Self, AppError> { | |
| 145 | + | let normalized = s.trim().to_lowercase(); | |
| 146 | + | if normalized.len() > EMAIL_MAX_LEN | |
| 147 | + | || !email_address::EmailAddress::is_valid(&normalized) | |
| 148 | + | { | |
| 149 | + | return Err(AppError::Validation( | |
| 150 | + | "Please enter a valid email address".into(), | |
| 151 | + | )); | |
| 152 | + | } | |
| 153 | + | Ok(Self(normalized)) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | /// Wrap a known-valid (already normalized) email without validation. | |
| 157 | + | /// Use for values read from the database. | |
| 158 | + | pub fn from_trusted(s: String) -> Self { | |
| 159 | + | Self(s) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | pub fn as_str(&self) -> &str { | |
| 163 | + | &self.0 | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | pub fn into_inner(self) -> String { | |
| 167 | + | self.0 | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | impl std::ops::Deref for Email { | |
| 172 | + | type Target = str; | |
| 173 | + | fn deref(&self) -> &str { | |
| 174 | + | &self.0 | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | impl AsRef<str> for Email { | |
| 179 | + | fn as_ref(&self) -> &str { | |
| 180 | + | &self.0 | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | impl std::fmt::Display for Email { | |
| 185 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 186 | + | self.0.fmt(f) | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | impl serde::Serialize for Email { | |
| 191 | + | fn serialize<S: serde::Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> { | |
| 192 | + | self.0.serialize(serializer) | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | impl<'de> serde::Deserialize<'de> for Email { | |
| 197 | + | fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> { | |
| 198 | + | let s = String::deserialize(deserializer)?; | |
| 199 | + | Email::new(&s).map_err(|e| serde::de::Error::custom(e.to_string())) | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | impl sqlx::Type<sqlx::Postgres> for Email { | |
| 204 | + | fn type_info() -> sqlx::postgres::PgTypeInfo { | |
| 205 | + | <String as sqlx::Type<sqlx::Postgres>>::type_info() | |
| 206 | + | } | |
| 207 | + | fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { | |
| 208 | + | <String as sqlx::Type<sqlx::Postgres>>::compatible(ty) | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | impl sqlx::Encode<'_, sqlx::Postgres> for Email { | |
| 213 | + | fn encode_by_ref( | |
| 214 | + | &self, | |
| 215 | + | buf: &mut sqlx::postgres::PgArgumentBuffer, | |
| 216 | + | ) -> std::result::Result<sqlx::encode::IsNull, Box<dyn std::error::Error + Send + Sync>> { | |
| 217 | + | <String as sqlx::Encode<'_, sqlx::Postgres>>::encode_by_ref(&self.0, buf) | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | impl sqlx::Decode<'_, sqlx::Postgres> for Email { | |
| 222 | + | fn decode( | |
| 223 | + | value: sqlx::postgres::PgValueRef<'_>, | |
| 224 | + | ) -> std::result::Result<Self, Box<dyn std::error::Error + Send + Sync>> { | |
| 225 | + | let s = <String as sqlx::Decode<'_, sqlx::Postgres>>::decode(value)?; | |
| 226 | + | Ok(Self(s)) | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | ||
| 124 | 230 | // ── Numeric newtype: PriceCents ── | |
| 125 | 231 | ||
| 126 | 232 | // ── Monetary types ── |
| @@ -86,7 +86,12 @@ | |||
| 86 | 86 | }; | |
| 87 | 87 | ||
| 88 | 88 | let user = if form.login.contains('@') { | |
| 89 | - | db::users::get_user_by_email(&state.db, &form.login) | |
| 89 | + | // Login form accepts username OR email. If '@' is present, try email lookup; | |
| 90 | + | // a malformed value just fails the lookup (None) — same generic error as wrong creds. | |
| 91 | + | let Ok(email) = db::Email::new(&form.login) else { | |
| 92 | + | return return_error("Invalid username or password"); | |
| 93 | + | }; | |
| 94 | + | db::users::get_user_by_email(&state.db, &email) | |
| 90 | 95 | .await | |
| 91 | 96 | .context("lookup user by email for login")? | |
| 92 | 97 | } else { |
| @@ -281,7 +281,9 @@ | |||
| 281 | 281 | ||
| 282 | 282 | // Find user by email or username | |
| 283 | 283 | let user = if login.contains('@') { | |
| 284 | - | db::users::get_user_by_email(&state.db, login).await? | |
| 284 | + | let email = db::Email::new(login) | |
| 285 | + | .map_err(|_| AppError::BadRequest("Invalid email".to_string()))?; | |
| 286 | + | db::users::get_user_by_email(&state.db, &email).await? | |
| 285 | 287 | } else { | |
| 286 | 288 | let username = Username::new(login).map_err(|_| AppError::BadRequest("Invalid username".to_string()))?; | |
| 287 | 289 | db::users::get_user_by_username(&state.db, &username).await? |
| @@ -27,7 +27,7 @@ | |||
| 27 | 27 | fn from(u: &db::DbUser) -> Self { | |
| 28 | 28 | User { | |
| 29 | 29 | username: u.username.to_string(), | |
| 30 | - | email: u.email.clone(), | |
| 30 | + | email: u.email.to_string(), | |
| 31 | 31 | display_name: u.display_name.clone(), | |
| 32 | 32 | bio: u.bio.clone(), | |
| 33 | 33 | avatar_initials: get_initials( | |
| @@ -118,7 +118,7 @@ | |||
| 118 | 118 | AdminWaitlistRow { | |
| 119 | 119 | id: e.id.to_string(), | |
| 120 | 120 | username: e.username.to_string(), | |
| 121 | - | email: e.email.clone(), | |
| 121 | + | email: e.email.to_string(), | |
| 122 | 122 | email_verified: e.email_verified, | |
| 123 | 123 | pitch: e.pitch.clone(), | |
| 124 | 124 | status: e.status.to_string(), |
| @@ -607,7 +607,7 @@ | |||
| 607 | 607 | Self { | |
| 608 | 608 | id: u.id.to_string(), | |
| 609 | 609 | username: u.username.to_string(), | |
| 610 | - | email: u.email.clone(), | |
| 610 | + | email: u.email.to_string(), | |
| 611 | 611 | display_name: u.display_name.clone(), | |
| 612 | 612 | is_suspended: u.is_suspended(), | |
| 613 | 613 | suspension_reason: u.suspension_reason.clone(), | |
| @@ -689,7 +689,7 @@ | |||
| 689 | 689 | Self { | |
| 690 | 690 | user_id: u.id.to_string(), | |
| 691 | 691 | username: u.username.to_string(), | |
| 692 | - | email: u.email.clone(), | |
| 692 | + | email: u.email.to_string(), | |
| 693 | 693 | suspension_reason: u.suspension_reason.clone(), | |
| 694 | 694 | appeal_text: u.appeal_text.clone().unwrap_or_default(), | |
| 695 | 695 | appeal_submitted_at: u |
| @@ -3,25 +3,6 @@ | |||
| 3 | 3 | use crate::error::AppError; | |
| 4 | 4 | use super::limits; | |
| 5 | 5 | ||
| 6 | - | /// RFC 5321 total length cap. The grammar allows more in places, but addresses | |
| 7 | - | /// longer than this won't survive any real mail transport. | |
| 8 | - | const EMAIL_MAX_LEN: usize = 254; | |
| 9 | - | ||
| 10 | - | /// Normalize and validate an email address. | |
| 11 | - | /// | |
| 12 | - | /// Returns the trimmed-lowercased form on success. Rejects RFC-invalid syntax | |
| 13 | - | /// and addresses longer than 254 characters. Used at public entry points | |
| 14 | - | /// (notify-me signup, guest checkout) where we have no follow-up verification. | |
| 15 | - | pub fn normalize_email(input: &str) -> Result<String, AppError> { | |
| 16 | - | let email = input.trim().to_lowercase(); | |
| 17 | - | if email.len() > EMAIL_MAX_LEN || !email_address::EmailAddress::is_valid(&email) { | |
| 18 | - | return Err(AppError::Validation( | |
| 19 | - | "Please enter a valid email address".into(), | |
| 20 | - | )); | |
| 21 | - | } | |
| 22 | - | Ok(email) | |
| 23 | - | } | |
| 24 | - | ||
| 25 | 6 | /// Validate a display name | |
| 26 | 7 | pub fn validate_display_name(name: &str) -> Result<(), AppError> { | |
| 27 | 8 | if name.chars().count() > limits::DISPLAY_NAME_MAX { |
| @@ -42,7 +42,7 @@ | |||
| 42 | 42 | pub user_id: UserId, | |
| 43 | 43 | pub username: Username, | |
| 44 | 44 | pub display_name: Option<String>, | |
| 45 | - | pub email: String, | |
| 45 | + | pub email: Email, | |
| 46 | 46 | pub creator_tier: Option<CreatorTier>, | |
| 47 | 47 | pub can_create_projects: bool, | |
| 48 | 48 | pub suspended: bool, |