fabrica

hanna/fabrica

feat: email verification via SMTP and `fabrica user verify`

5c1ec80 · hanna committed on 2026-07-25

Add email-address verification (migration 0009: user_emails.verified_at +
email_tokens). Adding an email or self-registering issues a 24h single-use
token and emails a /verify-email/{token} link; redeeming it marks the address
verified. Unverified addresses show a badge with a Resend button and cannot be
made primary. Existing addresses are back-filled as verified, and admin/CLI/
invite accounts are created verified. `fabrica user verify <name>` manually
verifies a user's primary email for instances without SMTP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
10 files changed · +346 −16UnifiedSplit
assets/base.css +8 −0
@@ -825,6 +825,14 @@ body.drawer-open .drawer-backdrop {
825825 .email-list {
826826 margin-bottom: 0;
827827 }
828+.badge-verified {
829+ color: var(--fb-success);
830+ border-color: var(--fb-success);
831+}
832+.badge-unverified {
833+ color: var(--fb-fg-muted);
834+ border-color: var(--fb-border);
835+}
828836 .email-visibility {
829837 margin: 0;
830838 padding: 0.85rem 0;
crates/cli/src/user_cmd.rs +22 −0
@@ -60,6 +60,12 @@ pub(crate) enum UserCommand {
6060 /// The user to enable.
6161 name: String,
6262 },
63+ /// Manually mark a user's primary email verified (for when SMTP is not set
64+ /// up, or to vouch for an account).
65+ Verify {
66+ /// The user to verify.
67+ name: String,
68+ },
6369 /// Delete a user. Refuses if the user still owns repositories unless
6470 /// `--purge` is given.
6571 Del {
@@ -99,6 +105,7 @@ pub(crate) fn run(
99105 UserCommand::List => block_on(list(config_path, json)),
100106 UserCommand::Disable { name } => block_on(set_disabled(config_path, name, true)),
101107 UserCommand::Enable { name } => block_on(set_disabled(config_path, name, false)),
108+ UserCommand::Verify { name } => block_on(verify(config_path, name)),
102109 UserCommand::Del { name, purge, yes } => block_on(del(config_path, name, *purge, *yes)),
103110 }
104111 }
@@ -282,6 +289,21 @@ async fn set_disabled(
282289 Ok(ExitCode::SUCCESS)
283290 }
284291
292+/// Manually mark a user's primary email verified.
293+async fn verify(config_path: Option<&Path>, name: &str) -> anyhow::Result<ExitCode> {
294+ let (_config, store) = open_store(config_path).await?;
295+ let user = require_user(&store, name).await?;
296+ if store.verify_primary_email(&user.id).await? {
297+ println!(
298+ "verified {}'s primary email ({})",
299+ user.username, user.email
300+ );
301+ } else {
302+ println!("{} has no primary email to verify", user.username);
303+ }
304+ Ok(ExitCode::SUCCESS)
305+}
306+
285307 async fn del(
286308 config_path: Option<&Path>,
287309 name: &str,
crates/mail/src/templates.rs +8 −0
@@ -12,6 +12,8 @@ pub enum Purpose {
1212 Activate,
1313 /// A password-reset link.
1414 Reset,
15+ /// An email-address verification link.
16+ VerifyEmail,
1517 }
1618
1719 impl Purpose {
@@ -21,6 +23,7 @@ impl Purpose {
2123 match self {
2224 Self::Activate => "activate",
2325 Self::Reset => "reset",
26+ Self::VerifyEmail => "verify-email",
2427 }
2528 }
2629 }
@@ -51,6 +54,11 @@ pub fn render(instance_name: &str, link: &str, purpose: Purpose) -> Rendered {
5154 format!("A password reset was requested for your {instance_name} account."),
5255 "choose a new password",
5356 ),
57+ Purpose::VerifyEmail => (
58+ format!("Verify your email for {instance_name}"),
59+ format!("Confirm this email address to use it on {instance_name}."),
60+ "verify this email address",
61+ ),
5462 };
5563
5664 let plain = format!(
crates/model/src/entity.rs +10 −0
@@ -114,10 +114,20 @@ pub struct UserEmail {
114114 pub email: String,
115115 /// Whether this is the user's primary address.
116116 pub is_primary: bool,
117+ /// When the address was verified, or `None` if still unverified.
118+ pub verified_at: Option<Timestamp>,
117119 /// Creation time.
118120 pub created_at: Timestamp,
119121 }
120122
123+impl UserEmail {
124+ /// Whether the address has been verified.
125+ #[must_use]
126+ pub fn verified(&self) -> bool {
127+ self.verified_at.is_some()
128+ }
129+}
130+
121131 /// A registered account.
122132 #[derive(Debug, Clone, PartialEq, Eq)]
123133 pub struct User {
crates/store/src/users.rs +131 −11
@@ -132,10 +132,13 @@ impl Store {
132132 .map_err(|e| {
133133 map_conflict(e, "user", &[("email", "email"), ("username", "username")])
134134 })?;
135- // Seed the primary email row so `user_emails` holds every address.
135+ // Seed the primary email row so `user_emails` holds every address. It is
136+ // created verified — admin/CLI/invite accounts are trusted; the
137+ // self-registration path explicitly re-opens verification afterwards.
136138 sqlx::query(
137- "INSERT INTO user_emails (id, user_id, email, email_lower, is_primary, created_at) \
138- VALUES ($1, $2, $3, $4, $5, $6)",
139+ "INSERT INTO user_emails \
140+ (id, user_id, email, email_lower, is_primary, verified_at, created_at) \
141+ VALUES ($1, $2, $3, $4, $5, $6, $7)",
139142 )
140143 .bind(new_id())
141144 .bind(user.id.clone())
@@ -143,6 +146,7 @@ impl Store {
143146 .bind(user.email.to_lowercase())
144147 .bind(true)
145148 .bind(user.created_at)
149+ .bind(user.created_at)
146150 .execute(&mut *tx)
147151 .await
148152 .map_err(|e| map_conflict(e, "user", &[("email_lower", "email")]))?;
@@ -250,7 +254,7 @@ impl Store {
250254 /// Returns [`StoreError::Query`] on a database failure.
251255 pub async fn emails_by_user(&self, user_id: &str) -> Result<Vec<model::UserEmail>, StoreError> {
252256 let rows = sqlx::query(
253- "SELECT id, user_id, email, is_primary, created_at FROM user_emails \
257+ "SELECT id, user_id, email, is_primary, verified_at, created_at FROM user_emails \
254258 WHERE user_id = $1 ORDER BY is_primary DESC, created_at ASC",
255259 )
256260 .bind(user_id)
@@ -263,6 +267,7 @@ impl Store {
263267 user_id: r.try_get("user_id")?,
264268 email: r.try_get("email")?,
265269 is_primary: self.get_bool(r, "is_primary")?,
270+ verified_at: r.try_get("verified_at")?,
266271 created_at: r.try_get("created_at")?,
267272 })
268273 })
@@ -286,6 +291,7 @@ impl Store {
286291 user_id: user_id.to_string(),
287292 email: email.to_string(),
288293 is_primary: false,
294+ verified_at: None,
289295 created_at: now_ms(),
290296 };
291297 sqlx::query(
@@ -322,7 +328,8 @@ impl Store {
322328 Ok(deleted > 0)
323329 }
324330
325- /// Make one of a user's addresses primary, mirroring it into `users.email`.
331+ /// Make one of a user's **verified** addresses primary, mirroring it into
332+ /// `users.email`. Returns `false` if the address is missing or unverified.
326333 ///
327334 /// # Errors
328335 ///
@@ -332,12 +339,15 @@ impl Store {
332339 user_id: &str,
333340 email_id: &str,
334341 ) -> Result<bool, StoreError> {
335- // Confirm the address belongs to the user and capture its value.
336- let row = sqlx::query("SELECT email FROM user_emails WHERE id = $1 AND user_id = $2")
337- .bind(email_id)
338- .bind(user_id)
339- .fetch_optional(&self.pool)
340- .await?;
342+ // Confirm the address belongs to the user, is verified, and capture it.
343+ let row = sqlx::query(
344+ "SELECT email FROM user_emails \
345+ WHERE id = $1 AND user_id = $2 AND verified_at IS NOT NULL",
346+ )
347+ .bind(email_id)
348+ .bind(user_id)
349+ .fetch_optional(&self.pool)
350+ .await?;
341351 let Some(row) = row else {
342352 return Ok(false);
343353 };
@@ -380,6 +390,116 @@ impl Store {
380390 Ok(())
381391 }
382392
393+ /// Begin (or restart) verification of an email address: mark it unverified,
394+ /// drop any outstanding tokens, and store a fresh `token` valid for 24 hours.
395+ /// The caller generates the random token and emails the link.
396+ ///
397+ /// # Errors
398+ ///
399+ /// Returns [`StoreError::Query`] on a database failure.
400+ pub async fn begin_email_verification(
401+ &self,
402+ email_id: &str,
403+ token: &str,
404+ ) -> Result<(), StoreError> {
405+ let now = now_ms();
406+ let mut tx = self.pool.begin().await?;
407+ sqlx::query("UPDATE user_emails SET verified_at = NULL WHERE id = $1")
408+ .bind(email_id)
409+ .execute(&mut *tx)
410+ .await?;
411+ sqlx::query("DELETE FROM email_tokens WHERE email_id = $1")
412+ .bind(email_id)
413+ .execute(&mut *tx)
414+ .await?;
415+ sqlx::query(
416+ "INSERT INTO email_tokens (token, email_id, expires_at, created_at) \
417+ VALUES ($1, $2, $3, $4)",
418+ )
419+ .bind(token)
420+ .bind(email_id)
421+ .bind(now + 24 * 60 * 60 * 1000)
422+ .bind(now)
423+ .execute(&mut *tx)
424+ .await?;
425+ tx.commit().await?;
426+ Ok(())
427+ }
428+
429+ /// Redeem a verification token: mark its address verified and consume the
430+ /// token. Returns the owning user id, or `None` if the token is unknown or
431+ /// expired.
432+ ///
433+ /// # Errors
434+ ///
435+ /// Returns [`StoreError::Query`] on a database failure.
436+ pub async fn verify_email_token(&self, token: &str) -> Result<Option<String>, StoreError> {
437+ let now = now_ms();
438+ let row = sqlx::query(
439+ "SELECT t.email_id, e.user_id FROM email_tokens t \
440+ JOIN user_emails e ON e.id = t.email_id \
441+ WHERE t.token = $1 AND t.expires_at > $2",
442+ )
443+ .bind(token)
444+ .bind(now)
445+ .fetch_optional(&self.pool)
446+ .await?;
447+ let Some(row) = row else {
448+ return Ok(None);
449+ };
450+ let email_id: String = row.try_get("email_id")?;
451+ let user_id: String = row.try_get("user_id")?;
452+
453+ let mut tx = self.pool.begin().await?;
454+ sqlx::query("UPDATE user_emails SET verified_at = $1 WHERE id = $2")
455+ .bind(now)
456+ .bind(&email_id)
457+ .execute(&mut *tx)
458+ .await?;
459+ sqlx::query("DELETE FROM email_tokens WHERE token = $1")
460+ .bind(token)
461+ .execute(&mut *tx)
462+ .await?;
463+ tx.commit().await?;
464+ Ok(Some(user_id))
465+ }
466+
467+ /// Look up an email row id owned by `user_id`, for resending verification.
468+ ///
469+ /// # Errors
470+ ///
471+ /// Returns [`StoreError::Query`] on a database failure.
472+ pub async fn email_owned_by(
473+ &self,
474+ user_id: &str,
475+ email_id: &str,
476+ ) -> Result<Option<model::UserEmail>, StoreError> {
477+ Ok(self
478+ .emails_by_user(user_id)
479+ .await?
480+ .into_iter()
481+ .find(|e| e.id == email_id))
482+ }
483+
484+ /// Mark the user's primary email verified (the manual `user verify` path,
485+ /// used when SMTP is unavailable).
486+ ///
487+ /// # Errors
488+ ///
489+ /// Returns [`StoreError::Query`] on a database failure.
490+ pub async fn verify_primary_email(&self, user_id: &str) -> Result<bool, StoreError> {
491+ let updated = sqlx::query(
492+ "UPDATE user_emails SET verified_at = $1 WHERE user_id = $2 AND is_primary = $3",
493+ )
494+ .bind(now_ms())
495+ .bind(user_id)
496+ .bind(true)
497+ .execute(&self.pool)
498+ .await?
499+ .rows_affected();
500+ Ok(updated > 0)
501+ }
502+
383503 /// Set (or clear) a user's password hash.
384504 ///
385505 /// Passing `Some(hash)` sets the credential and clears the
crates/web/src/lib.rs +2 −0
@@ -149,6 +149,7 @@ pub fn build_router(state: AppState) -> Router {
149149 "/invite/{token}",
150150 get(pages::invite_form).post(pages::invite_submit),
151151 )
152+ .route("/verify-email/{token}", get(pages::verify_email))
152153 .route("/search", get(search::global))
153154 .route(
154155 "/settings",
@@ -159,6 +160,7 @@ pub fn build_router(state: AppState) -> Router {
159160 .route("/settings/emails", post(pages::email_add))
160161 .route("/settings/emails/primary", post(pages::email_primary))
161162 .route("/settings/emails/delete", post(pages::email_delete))
163+ .route("/settings/emails/resend", post(pages::email_resend))
162164 .route("/settings/emails/visibility", post(pages::email_visibility))
163165 .route("/settings/password", post(pages::account_password))
164166 .route(
crates/web/src/pages.rs +109 −4
@@ -765,6 +765,13 @@ pub async fn register_submit(
765765 Err(err) => return AppError::from(err).into_response(),
766766 };
767767
768+ // Send a verification email for the new primary address (best-effort).
769+ if let Ok(emails) = state.store.emails_by_user(&user.id).await
770+ && let Some(primary) = emails.into_iter().find(|e| e.is_primary)
771+ {
772+ send_email_verification(&state, &primary).await;
773+ }
774+
768775 match open_session(&state, &jar, &user, &headers).await {
769776 Ok(jar) => (jar, Redirect::to("/")).into_response(),
770777 Err(err) => err.into_response(),
@@ -1137,7 +1144,11 @@ pub async fn email_add(
11371144 return AppError::BadRequest("Enter a valid email address.".to_string()).into_response();
11381145 }
11391146 match state.store.add_email(&user.id, email).await {
1140- Ok(_) => Redirect::to("/settings/account").into_response(),
1147+ Ok(added) => {
1148+ // Send a verification link (best-effort; logged on failure).
1149+ send_email_verification(&state, &added).await;
1150+ Redirect::to("/settings/account").into_response()
1151+ }
11411152 Err(store::StoreError::Conflict { .. }) => {
11421153 AppError::BadRequest("That email is already in use.".to_string()).into_response()
11431154 }
@@ -1145,6 +1156,80 @@ pub async fn email_add(
11451156 }
11461157 }
11471158
1159+/// Issue a fresh verification token for `email` and email the link. Best-effort:
1160+/// store or mail failures are logged, not surfaced (the user can resend).
1161+async fn send_email_verification(state: &AppState, email: &model::UserEmail) {
1162+ let token = auth::new_secret();
1163+ if let Err(err) = state
1164+ .store
1165+ .begin_email_verification(&email.id, &token)
1166+ .await
1167+ {
1168+ tracing::warn!(error = %err, "could not store email verification token");
1169+ return;
1170+ }
1171+ let base = state.config.instance.url.trim_end_matches('/');
1172+ let link = format!("{base}/verify-email/{token}");
1173+ if let Err(err) = state
1174+ .mailer
1175+ .send_invite(
1176+ &state.config.instance.name,
1177+ &email.email,
1178+ None,
1179+ &link,
1180+ mail::Purpose::VerifyEmail,
1181+ )
1182+ .await
1183+ {
1184+ tracing::warn!(error = %err, email = %email.email, "could not send verification email");
1185+ }
1186+}
1187+
1188+/// `GET /verify-email/{token}` — redeem an email verification link.
1189+pub async fn verify_email(
1190+ State(state): State<AppState>,
1191+ jar: CookieJar,
1192+ Path(token): Path<String>,
1193+) -> Response {
1194+ match state.store.verify_email_token(&token).await {
1195+ Ok(Some(_)) => {
1196+ let (jar, chrome) = build_chrome(&state, jar, None, "/verify-email".to_string());
1197+ let body = html! {
1198+ div class="auth-wrap" {
1199+ div class="auth-card" {
1200+ h1 { "Email verified" }
1201+ p class="muted" { "Your email address has been verified." }
1202+ p { a class="btn btn-primary" href="/settings/account" { "Back to settings" } }
1203+ }
1204+ }
1205+ };
1206+ (jar, page(&chrome, "Email verified", body)).into_response()
1207+ }
1208+ Ok(None) => {
1209+ AppError::BadRequest("That verification link is invalid or has expired.".to_string())
1210+ .into_response()
1211+ }
1212+ Err(err) => AppError::from(err).into_response(),
1213+ }
1214+}
1215+
1216+/// `POST /settings/emails/resend` — resend a verification email.
1217+pub async fn email_resend(
1218+ State(state): State<AppState>,
1219+ RequireUser(user): RequireUser,
1220+ jar: CookieJar,
1221+ headers: HeaderMap,
1222+ Form(form): Form<EmailIdForm>,
1223+) -> Response {
1224+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1225+ return AppError::Forbidden.into_response();
1226+ }
1227+ if let Ok(Some(email)) = state.store.email_owned_by(&user.id, &form.id).await {
1228+ send_email_verification(&state, &email).await;
1229+ }
1230+ Redirect::to("/settings/account").into_response()
1231+}
1232+
11481233 /// An email-id form (set-primary or delete).
11491234 #[derive(Debug, Deserialize)]
11501235 pub struct EmailIdForm {
@@ -1167,8 +1252,14 @@ pub async fn email_primary(
11671252 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
11681253 return AppError::Forbidden.into_response();
11691254 }
1170- let _ = state.store.set_primary_email(&user.id, &form.id).await;
1171- Redirect::to("/settings/account").into_response()
1255+ match state.store.set_primary_email(&user.id, &form.id).await {
1256+ Ok(true) => Redirect::to("/settings/account").into_response(),
1257+ Ok(false) => {
1258+ AppError::BadRequest("Verify the email address before making it primary.".to_string())
1259+ .into_response()
1260+ }
1261+ Err(err) => AppError::from(err).into_response(),
1262+ }
11721263 }
11731264
11741265 /// `POST /settings/emails/delete` — remove one of the viewer's secondary emails.
@@ -1642,15 +1733,29 @@ fn emails_section(user: &User, csrf: &str, emails: &[model::UserEmail]) -> Marku
16421733 span class="entry-row-title" {
16431734 (e.email)
16441735 @if e.is_primary { " " span class="badge" { "primary" } }
1736+ @if e.verified() {
1737+ " " span class="badge badge-verified" { "verified" }
1738+ } @else {
1739+ " " span class="badge badge-unverified" { "unverified" }
1740+ }
16451741 }
16461742 }
16471743 div class="entry-row-actions" {
1648- @if !e.is_primary {
1744+ @if !e.verified() {
1745+ form method="post" action="/settings/emails/resend" {
1746+ input type="hidden" name="_csrf" value=(csrf);
1747+ input type="hidden" name="id" value=(e.id);
1748+ button class="btn" type="submit" { "Resend" }
1749+ }
1750+ }
1751+ @if !e.is_primary && e.verified() {
16491752 form method="post" action="/settings/emails/primary" {
16501753 input type="hidden" name="_csrf" value=(csrf);
16511754 input type="hidden" name="id" value=(e.id);
16521755 button class="btn" type="submit" { "Make primary" }
16531756 }
1757+ }
1758+ @if !e.is_primary {
16541759 form method="post" action="/settings/emails/delete" {
16551760 input type="hidden" name="_csrf" value=(csrf);
16561761 input type="hidden" name="id" value=(e.id);
crates/web/src/tests.rs +30 −1
@@ -522,9 +522,38 @@ async fn emails_add_set_primary_and_publish_on_profile() {
522522 .find(|e| e.email == "second@example.com")
523523 .unwrap();
524524 assert!(!second.is_primary);
525+ assert!(!second.verified(), "added emails start unverified");
525526 let second_id = second.id.clone();
526527
527- // Make it primary; users.email mirrors it.
528+ // A new address must be verified before it can become primary.
529+ let res = post(
530+ "/settings/emails/primary",
531+ format!("id={second_id}&_csrf={token}"),
532+ cookie.clone(),
533+ )
534+ .await;
535+ assert_eq!(
536+ res.status(),
537+ StatusCode::BAD_REQUEST,
538+ "unverified cannot be primary"
539+ );
540+
541+ // Exercise the verification flow: token issued, then redeemed.
542+ state
543+ .store
544+ .begin_email_verification(&second_id, "verify-token")
545+ .await
546+ .unwrap();
547+ assert!(
548+ state
549+ .store
550+ .verify_email_token("verify-token")
551+ .await
552+ .unwrap()
553+ .is_some()
554+ );
555+
556+ // Now it can be made primary; users.email mirrors it.
528557 let res = post(
529558 "/settings/emails/primary",
530559 format!("id={second_id}&_csrf={token}"),
migrations/postgres/0009_email_verification.sql +13 −0
@@ -0,0 +1,13 @@
1+-- Email-address verification: a nullable verified timestamp plus single-use,
2+-- expiring verification tokens. Addresses that predate this migration are
3+-- trusted (back-filled as verified).
4+ALTER TABLE user_emails ADD COLUMN verified_at BIGINT;
5+UPDATE user_emails SET verified_at = created_at;
6+
7+CREATE TABLE email_tokens (
8+ token TEXT PRIMARY KEY,
9+ email_id TEXT NOT NULL REFERENCES user_emails(id) ON DELETE CASCADE,
10+ expires_at BIGINT NOT NULL,
11+ created_at BIGINT NOT NULL
12+);
13+CREATE INDEX idx_email_tokens_email ON email_tokens (email_id);
migrations/sqlite/0009_email_verification.sql +13 −0
@@ -0,0 +1,13 @@
1+-- Email-address verification: a nullable verified timestamp plus single-use,
2+-- expiring verification tokens. Addresses that predate this migration are
3+-- trusted (back-filled as verified).
4+ALTER TABLE user_emails ADD COLUMN verified_at BIGINT;
5+UPDATE user_emails SET verified_at = created_at;
6+
7+CREATE TABLE email_tokens (
8+ token TEXT PRIMARY KEY,
9+ email_id TEXT NOT NULL REFERENCES user_emails(id) ON DELETE CASCADE,
10+ expires_at BIGINT NOT NULL,
11+ created_at BIGINT NOT NULL
12+);
13+CREATE INDEX idx_email_tokens_email ON email_tokens (email_id);