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 @@
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,