max / makenotwork
19 files changed,
+182 insertions,
-61 deletions
| @@ -86,7 +86,7 @@ impl SessionUser { | |||
| 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, |
| @@ -42,7 +42,7 @@ pub struct SshKeyUserLookup { | |||
| 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, |
| @@ -59,8 +59,9 @@ pub struct DbUser { | |||
| 59 | 59 | pub id: UserId, | |
| 60 | 60 | /// Unique login handle. | |
| 61 | 61 | pub username: Username, | |
| 62 | - | /// Unique email address. | |
| 63 | - | pub email: String, | |
| 62 | + | /// Unique email address. Normalized (trimmed + lowercased) at write time | |
| 63 | + | /// via [`Email::new`]; DB reads use `from_trusted`. | |
| 64 | + | pub email: Email, | |
| 64 | 65 | /// Argon2-hashed password. | |
| 65 | 66 | pub password_hash: String, | |
| 66 | 67 | /// Optional human-readable name shown on profile. | |
| @@ -247,7 +248,7 @@ mod tests { | |||
| 247 | 248 | DbUser { | |
| 248 | 249 | id: UserId::nil(), | |
| 249 | 250 | username: Username::from_trusted("test".to_string()), | |
| 250 | - | email: "test@example.com".to_string(), | |
| 251 | + | email: Email::from_trusted("test@example.com".to_string()), | |
| 251 | 252 | password_hash: String::new(), | |
| 252 | 253 | display_name: None, | |
| 253 | 254 | bio: None, |
| @@ -116,7 +116,7 @@ pub struct DbAdminWaitlistRow { | |||
| 116 | 116 | /// Applicant's username (joined from users). | |
| 117 | 117 | pub username: Username, | |
| 118 | 118 | /// Applicant's email (joined from users). | |
| 119 | - | pub email: String, | |
| 119 | + | pub email: Email, | |
| 120 | 120 | /// Whether the applicant's email is verified (joined from users). | |
| 121 | 121 | pub email_verified: bool, | |
| 122 | 122 | /// User who invited this applicant (if invited). |
| @@ -4,7 +4,7 @@ use sqlx::PgPool; | |||
| 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 @@ use crate::error::Result; | |||
| 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 @@ pub async fn get_user_by_username(pool: &PgPool, username: &Username) -> Result< | |||
| 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 @@ pub async fn get_expired_content_removal_ids(pool: &PgPool) -> Result<Vec<UserId | |||
| 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 @@ pub async fn disable_notification(pool: &PgPool, user_id: UserId, preference: &s | |||
| 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 @@ pub async fn bump_cache_generation(pool: &PgPool, user_id: UserId) -> Result<()> | |||
| 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 @@ define_pg_string_newtype!( | |||
| 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 ── |
| @@ -321,7 +321,7 @@ pub(super) async fn claim_free_guest( | |||
| 321 | 321 | Path(item_id): Path<ItemId>, | |
| 322 | 322 | Json(body): Json<FreeGuestClaimRequest>, | |
| 323 | 323 | ) -> Result<Response> { | |
| 324 | - | let email = crate::validation::normalize_email(&body.email) | |
| 324 | + | let email = db::Email::new(&body.email) | |
| 325 | 325 | .map_err(|_| AppError::BadRequest("Invalid email address".to_string()))?; | |
| 326 | 326 | ||
| 327 | 327 | let item = db::items::get_item_by_id(&state.db, item_id) | |
| @@ -360,7 +360,7 @@ pub(super) async fn claim_free_guest( | |||
| 360 | 360 | &checkout_session_id, | |
| 361 | 361 | &item.title, | |
| 362 | 362 | &seller.username, | |
| 363 | - | &email, | |
| 363 | + | email.as_str(), | |
| 364 | 364 | claim_token, | |
| 365 | 365 | download_token, | |
| 366 | 366 | ) | |
| @@ -395,7 +395,7 @@ pub(super) async fn claim_free_guest( | |||
| 395 | 395 | ||
| 396 | 396 | if existing_user_id.is_none() { | |
| 397 | 397 | let email_client = state.email.clone(); | |
| 398 | - | let email_addr = email.clone(); | |
| 398 | + | let email_addr = email.clone().into_inner(); | |
| 399 | 399 | let item_title = item.title.clone(); | |
| 400 | 400 | let dl_url = download_url.clone(); | |
| 401 | 401 | let cl_url = claim_url.clone(); |
| @@ -160,8 +160,8 @@ async fn email_signup( | |||
| 160 | 160 | State(state): State<AppState>, | |
| 161 | 161 | Json(form): Json<EmailSignupForm>, | |
| 162 | 162 | ) -> Result<impl IntoResponse> { | |
| 163 | - | let email = crate::validation::normalize_email(&form.email)?; | |
| 164 | - | db::email_signups::insert_email_signup(&state.db, &email, "landing").await?; | |
| 163 | + | let email = db::Email::new(&form.email)?; | |
| 164 | + | db::email_signups::insert_email_signup(&state.db, email.as_str(), "landing").await?; | |
| 165 | 165 | Ok(Json(json!({"success": true}))) | |
| 166 | 166 | } | |
| 167 | 167 |
| @@ -86,7 +86,12 @@ async fn login_handler( | |||
| 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 @@ async fn authorize_post( | |||
| 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? |
| @@ -48,7 +48,11 @@ pub(super) async fn forgot_password_handler( | |||
| 48 | 48 | ); | |
| 49 | 49 | ||
| 50 | 50 | // Look up user by email | |
| 51 | - | let user = match db::users::get_user_by_email(&state.db, &form.email).await? { | |
| 51 | + | let Ok(email) = db::Email::new(&form.email) else { | |
| 52 | + | // Same generic response as "email exists but no account" to avoid leaking validity. | |
| 53 | + | return Ok(success_alert.into_response()); | |
| 54 | + | }; | |
| 55 | + | let user = match db::users::get_user_by_email(&state.db, &email).await? { | |
| 52 | 56 | Some(u) => u, | |
| 53 | 57 | None => { | |
| 54 | 58 | // Don't reveal that email doesn't exist |
| @@ -85,21 +85,17 @@ pub async fn step_account_create( | |||
| 85 | 85 | } | |
| 86 | 86 | }; | |
| 87 | 87 | ||
| 88 | - | // Validate email format | |
| 89 | - | let email_str = form.email.trim(); | |
| 90 | - | if let Some(at_pos) = email_str.find('@') { | |
| 91 | - | if !email_str[at_pos + 1..].contains('.') { | |
| 92 | - | return return_error("Please enter a valid email address"); | |
| 93 | - | } | |
| 94 | - | } else { | |
| 95 | - | return return_error("Please enter a valid email address"); | |
| 96 | - | } | |
| 88 | + | // Validate and normalize email | |
| 89 | + | let email = match db::Email::new(&form.email) { | |
| 90 | + | Ok(e) => e, | |
| 91 | + | Err(_) => return return_error("Please enter a valid email address"), | |
| 92 | + | }; | |
| 97 | 93 | ||
| 98 | 94 | // Check uniqueness | |
| 99 | 95 | let username_taken = db::users::get_user_by_username(&state.db, &form.username) | |
| 100 | 96 | .await? | |
| 101 | 97 | .is_some(); | |
| 102 | - | let email_taken = db::users::get_user_by_email(&state.db, &form.email) | |
| 98 | + | let email_taken = db::users::get_user_by_email(&state.db, &email) | |
| 103 | 99 | .await? | |
| 104 | 100 | .is_some(); | |
| 105 | 101 | if username_taken && email_taken { | |
| @@ -137,7 +133,7 @@ pub async fn step_account_create( | |||
| 137 | 133 | // Hash password and create user | |
| 138 | 134 | let password_hash = hash_password(&form.password)?; | |
| 139 | 135 | let user = | |
| 140 | - | db::users::create_user(&state.db, &form.username, &form.email, &password_hash).await?; | |
| 136 | + | db::users::create_user(&state.db, &form.username, &email, &password_hash).await?; | |
| 141 | 137 | ||
| 142 | 138 | // Process invite code (if provided and valid) | |
| 143 | 139 | if let Some(ref code_raw) = form.invite_code { | |
| @@ -177,7 +173,7 @@ pub async fn step_account_create( | |||
| 177 | 173 | let session_user = SessionUser { | |
| 178 | 174 | id: user.id, | |
| 179 | 175 | username: user.username, | |
| 180 | - | email: user.email, | |
| 176 | + | email: user.email.into_inner(), | |
| 181 | 177 | display_name: user.display_name, | |
| 182 | 178 | can_create_projects: false, | |
| 183 | 179 | suspended: false, |
| @@ -78,7 +78,7 @@ pub(super) async fn create_sandbox( | |||
| 78 | 78 | .to_lowercase(); | |
| 79 | 79 | ||
| 80 | 80 | let username = db::Username::from_trusted(format!("sandbox_{}", suffix)); | |
| 81 | - | let email = format!("sandbox_{}@sandbox.local", suffix); | |
| 81 | + | let email = db::Email::from_trusted(format!("sandbox_{}@sandbox.local", suffix)); | |
| 82 | 82 | let password_hash = auth::hash_password(&format!("sandbox_{}", uuid::Uuid::new_v4()))?; | |
| 83 | 83 | ||
| 84 | 84 | // Create the sandbox user | |
| @@ -95,7 +95,7 @@ pub(super) async fn create_sandbox( | |||
| 95 | 95 | let session_user = SessionUser { | |
| 96 | 96 | id: user.id, | |
| 97 | 97 | username: user.username, | |
| 98 | - | email: user.email, | |
| 98 | + | email: user.email.into_inner(), | |
| 99 | 99 | display_name: user.display_name, | |
| 100 | 100 | can_create_projects: true, | |
| 101 | 101 | suspended: false, |
| @@ -54,7 +54,13 @@ async fn handle_new_issue( | |||
| 54 | 54 | repo_name: &str, | |
| 55 | 55 | ) -> StatusCode { | |
| 56 | 56 | // Look up sender — must be a verified, non-suspended MNW user | |
| 57 | - | let sender_email = payload.from_full.email.to_lowercase(); | |
| 57 | + | let sender_email = match db::Email::new(&payload.from_full.email) { | |
| 58 | + | Ok(e) => e, | |
| 59 | + | Err(_) => { | |
| 60 | + | tracing::info!(raw = %payload.from_full.email, "inbound-issues: sender email is malformed"); | |
| 61 | + | return StatusCode::OK; | |
| 62 | + | } | |
| 63 | + | }; | |
| 58 | 64 | let sender = match db::users::get_user_by_email(&state.db, &sender_email).await { | |
| 59 | 65 | Ok(Some(u)) if u.email_verified && !u.is_suspended() => u, | |
| 60 | 66 | Ok(Some(u)) if !u.email_verified => { | |
| @@ -212,7 +218,13 @@ async fn handle_issue_reply( | |||
| 212 | 218 | }; | |
| 213 | 219 | ||
| 214 | 220 | // Look up sender and verify they match the token | |
| 215 | - | let sender_email = payload.from_full.email.to_lowercase(); | |
| 221 | + | let sender_email = match db::Email::new(&payload.from_full.email) { | |
| 222 | + | Ok(e) => e, | |
| 223 | + | Err(_) => { | |
| 224 | + | tracing::info!(raw = %payload.from_full.email, "inbound-issues: reply sender email is malformed"); | |
| 225 | + | return StatusCode::OK; | |
| 226 | + | } | |
| 227 | + | }; | |
| 216 | 228 | let sender = match db::users::get_user_by_email(&state.db, &sender_email).await { | |
| 217 | 229 | Ok(Some(u)) if u.email_verified && !u.is_suspended() => u, | |
| 218 | 230 | Ok(Some(_)) => { |
| @@ -61,7 +61,13 @@ pub(super) async fn postmark_inbound( | |||
| 61 | 61 | }; | |
| 62 | 62 | ||
| 63 | 63 | // 4. Look up sender by email — must be a verified MNW user | |
| 64 | - | let sender_email = payload.from_full.email.to_lowercase(); | |
| 64 | + | let sender_email = match db::Email::new(&payload.from_full.email) { | |
| 65 | + | Ok(e) => e, | |
| 66 | + | Err(_) => { | |
| 67 | + | tracing::info!(raw = %payload.from_full.email, "inbound: sender email is malformed"); | |
| 68 | + | return StatusCode::OK; | |
| 69 | + | } | |
| 70 | + | }; | |
| 65 | 71 | let sender = match db::users::get_user_by_email(&state.db, &sender_email).await { | |
| 66 | 72 | Ok(Some(u)) if u.email_verified => u, | |
| 67 | 73 | Ok(Some(_)) => { |
| @@ -62,7 +62,15 @@ pub(super) async fn sync_auth( | |||
| 62 | 62 | ||
| 63 | 63 | // Verify user credentials — always run Argon2 before checking account | |
| 64 | 64 | // status to prevent timing oracles that leak suspension/lockout/2FA state. | |
| 65 | - | let user = match db::users::get_user_by_email(&state.db, &req.email).await? { | |
| 65 | + | let email = match db::Email::new(&req.email) { | |
| 66 | + | Ok(e) => e, | |
| 67 | + | Err(_) => { | |
| 68 | + | // Equalize timing on malformed input too — same enumeration concern. | |
| 69 | + | let _ = verify_password("dummy", &DUMMY_HASH); | |
| 70 | + | return Err(AppError::Unauthorized); | |
| 71 | + | } | |
| 72 | + | }; | |
| 73 | + | let user = match db::users::get_user_by_email(&state.db, &email).await? { | |
| 66 | 74 | Some(u) => u, | |
| 67 | 75 | None => { | |
| 68 | 76 | // Equalize timing to prevent email enumeration |
| @@ -27,7 +27,7 @@ impl From<&db::DbUser> for User { | |||
| 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 @@ impl From<&db::DbAdminWaitlistRow> for AdminWaitlistRow { | |||
| 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 @@ impl AdminUserRow { | |||
| 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 @@ impl AdminAppealRow { | |||
| 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 { |