Skip to main content

max / makenotwork

Add Email validated newtype, replace ad-hoc email handling Introduces db::Email — a validated, normalized newtype that trims and lowercases input on construction, enforces an RFC 5321 length cap, and validates syntax via the email_address crate. DB reads use Email::from_trusted to skip re-validation; all writes go through Email::new. Cascading type change: DbUser.email, DbAdminWaitlistRow.email, SshKeyUserLookup.email, StatusAlertSubscriber.email become Email; get_user_by_email and friends take &Email. Equality and lookups are case-insensitive by construction. Removes validation::normalize_email — superseded. Call sites: - /signup, /forgot-password, /join wizard, OAuth authorize, OAuth login - guest claim flow, sandbox creation - inbound Postmark webhook (issues + patches) - SyncKit auth, login form (username-or-email) Inbound webhook senders now reject malformed addresses early instead of attempting a doomed lookup. Login form treats a malformed email the same as wrong credentials (no enumeration). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-15 01:38 UTC
Commit: 4ea58fdfddde744c2f6ad49283329d1df63d6f5a
Parent: d8c8067
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 {