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 {
825.email-list {825.email-list {
826 margin-bottom: 0;826 margin-bottom: 0;
827}827}
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}
828.email-visibility {836.email-visibility {
829 margin: 0;837 margin: 0;
830 padding: 0.85rem 0;838 padding: 0.85rem 0;
crates/cli/src/user_cmd.rs +22 −0
@@ -60,6 +60,12 @@ pub(crate) enum UserCommand {
60 /// The user to enable.60 /// The user to enable.
61 name: String,61 name: String,
62 },62 },
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 },
63 /// Delete a user. Refuses if the user still owns repositories unless69 /// Delete a user. Refuses if the user still owns repositories unless
64 /// `--purge` is given.70 /// `--purge` is given.
65 Del {71 Del {
@@ -99,6 +105,7 @@ pub(crate) fn run(
99 UserCommand::List => block_on(list(config_path, json)),105 UserCommand::List => block_on(list(config_path, json)),
100 UserCommand::Disable { name } => block_on(set_disabled(config_path, name, true)),106 UserCommand::Disable { name } => block_on(set_disabled(config_path, name, true)),
101 UserCommand::Enable { name } => block_on(set_disabled(config_path, name, false)),107 UserCommand::Enable { name } => block_on(set_disabled(config_path, name, false)),
108 UserCommand::Verify { name } => block_on(verify(config_path, name)),
102 UserCommand::Del { name, purge, yes } => block_on(del(config_path, name, *purge, *yes)),109 UserCommand::Del { name, purge, yes } => block_on(del(config_path, name, *purge, *yes)),
103 }110 }
104}111}
@@ -282,6 +289,21 @@ async fn set_disabled(
282 Ok(ExitCode::SUCCESS)289 Ok(ExitCode::SUCCESS)
283}290}
284291
292/// Manually mark a user's primary email verified.
293async 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
285async fn del(307async fn del(
286 config_path: Option<&Path>,308 config_path: Option<&Path>,
287 name: &str,309 name: &str,
crates/mail/src/templates.rs +8 −0
@@ -12,6 +12,8 @@ pub enum Purpose {
12 Activate,12 Activate,
13 /// A password-reset link.13 /// A password-reset link.
14 Reset,14 Reset,
15 /// An email-address verification link.
16 VerifyEmail,
15}17}
1618
17impl Purpose {19impl Purpose {
@@ -21,6 +23,7 @@ impl Purpose {
21 match self {23 match self {
22 Self::Activate => "activate",24 Self::Activate => "activate",
23 Self::Reset => "reset",25 Self::Reset => "reset",
26 Self::VerifyEmail => "verify-email",
24 }27 }
25 }28 }
26}29}
@@ -51,6 +54,11 @@ pub fn render(instance_name: &str, link: &str, purpose: Purpose) -> Rendered {
51 format!("A password reset was requested for your {instance_name} account."),54 format!("A password reset was requested for your {instance_name} account."),
52 "choose a new password",55 "choose a new password",
53 ),56 ),
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 ),
54 };62 };
5563
56 let plain = format!(64 let plain = format!(
crates/model/src/entity.rs +10 −0
@@ -114,10 +114,20 @@ pub struct UserEmail {
114 pub email: String,114 pub email: String,
115 /// Whether this is the user's primary address.115 /// Whether this is the user's primary address.
116 pub is_primary: bool,116 pub is_primary: bool,
117 /// When the address was verified, or `None` if still unverified.
118 pub verified_at: Option<Timestamp>,
117 /// Creation time.119 /// Creation time.
118 pub created_at: Timestamp,120 pub created_at: Timestamp,
119}121}
120122
123impl 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
121/// A registered account.131/// A registered account.
122#[derive(Debug, Clone, PartialEq, Eq)]132#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct User {133pub struct User {
crates/store/src/users.rs +131 −11
@@ -132,10 +132,13 @@ impl Store {
132 .map_err(|e| {132 .map_err(|e| {
133 map_conflict(e, "user", &[("email", "email"), ("username", "username")])133 map_conflict(e, "user", &[("email", "email"), ("username", "username")])
134 })?;134 })?;
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.
136 sqlx::query(138 sqlx::query(
137 "INSERT INTO user_emails (id, user_id, email, email_lower, is_primary, created_at) \139 "INSERT INTO user_emails \
138 VALUES ($1, $2, $3, $4, $5, $6)",140 (id, user_id, email, email_lower, is_primary, verified_at, created_at) \
141 VALUES ($1, $2, $3, $4, $5, $6, $7)",
139 )142 )
140 .bind(new_id())143 .bind(new_id())
141 .bind(user.id.clone())144 .bind(user.id.clone())
@@ -143,6 +146,7 @@ impl Store {
143 .bind(user.email.to_lowercase())146 .bind(user.email.to_lowercase())
144 .bind(true)147 .bind(true)
145 .bind(user.created_at)148 .bind(user.created_at)
149 .bind(user.created_at)
146 .execute(&mut *tx)150 .execute(&mut *tx)
147 .await151 .await
148 .map_err(|e| map_conflict(e, "user", &[("email_lower", "email")]))?;152 .map_err(|e| map_conflict(e, "user", &[("email_lower", "email")]))?;
@@ -250,7 +254,7 @@ impl Store {
250 /// Returns [`StoreError::Query`] on a database failure.254 /// Returns [`StoreError::Query`] on a database failure.
251 pub async fn emails_by_user(&self, user_id: &str) -> Result<Vec<model::UserEmail>, StoreError> {255 pub async fn emails_by_user(&self, user_id: &str) -> Result<Vec<model::UserEmail>, StoreError> {
252 let rows = sqlx::query(256 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 \
254 WHERE user_id = $1 ORDER BY is_primary DESC, created_at ASC",258 WHERE user_id = $1 ORDER BY is_primary DESC, created_at ASC",
255 )259 )
256 .bind(user_id)260 .bind(user_id)
@@ -263,6 +267,7 @@ impl Store {
263 user_id: r.try_get("user_id")?,267 user_id: r.try_get("user_id")?,
264 email: r.try_get("email")?,268 email: r.try_get("email")?,
265 is_primary: self.get_bool(r, "is_primary")?,269 is_primary: self.get_bool(r, "is_primary")?,
270 verified_at: r.try_get("verified_at")?,
266 created_at: r.try_get("created_at")?,271 created_at: r.try_get("created_at")?,
267 })272 })
268 })273 })
@@ -286,6 +291,7 @@ impl Store {
286 user_id: user_id.to_string(),291 user_id: user_id.to_string(),
287 email: email.to_string(),292 email: email.to_string(),
288 is_primary: false,293 is_primary: false,
294 verified_at: None,
289 created_at: now_ms(),295 created_at: now_ms(),
290 };296 };
291 sqlx::query(297 sqlx::query(
@@ -322,7 +328,8 @@ impl Store {
322 Ok(deleted > 0)328 Ok(deleted > 0)
323 }329 }
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.
326 ///333 ///
327 /// # Errors334 /// # Errors
328 ///335 ///
@@ -332,12 +339,15 @@ impl Store {
332 user_id: &str,339 user_id: &str,
333 email_id: &str,340 email_id: &str,
334 ) -> Result<bool, StoreError> {341 ) -> Result<bool, StoreError> {
335 // Confirm the address belongs to the user and capture its value.342 // Confirm the address belongs to the user, is verified, and capture it.
336 let row = sqlx::query("SELECT email FROM user_emails WHERE id = $1 AND user_id = $2")343 let row = sqlx::query(
337 .bind(email_id)344 "SELECT email FROM user_emails \
338 .bind(user_id)345 WHERE id = $1 AND user_id = $2 AND verified_at IS NOT NULL",
339 .fetch_optional(&self.pool)346 )
340 .await?;347 .bind(email_id)
348 .bind(user_id)
349 .fetch_optional(&self.pool)
350 .await?;
341 let Some(row) = row else {351 let Some(row) = row else {
342 return Ok(false);352 return Ok(false);
343 };353 };
@@ -380,6 +390,116 @@ impl Store {
380 Ok(())390 Ok(())
381 }391 }
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
383 /// Set (or clear) a user's password hash.503 /// Set (or clear) a user's password hash.
384 ///504 ///
385 /// Passing `Some(hash)` sets the credential and clears the505 /// 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 {
149 "/invite/{token}",149 "/invite/{token}",
150 get(pages::invite_form).post(pages::invite_submit),150 get(pages::invite_form).post(pages::invite_submit),
151 )151 )
152 .route("/verify-email/{token}", get(pages::verify_email))
152 .route("/search", get(search::global))153 .route("/search", get(search::global))
153 .route(154 .route(
154 "/settings",155 "/settings",
@@ -159,6 +160,7 @@ pub fn build_router(state: AppState) -> Router {
159 .route("/settings/emails", post(pages::email_add))160 .route("/settings/emails", post(pages::email_add))
160 .route("/settings/emails/primary", post(pages::email_primary))161 .route("/settings/emails/primary", post(pages::email_primary))
161 .route("/settings/emails/delete", post(pages::email_delete))162 .route("/settings/emails/delete", post(pages::email_delete))
163 .route("/settings/emails/resend", post(pages::email_resend))
162 .route("/settings/emails/visibility", post(pages::email_visibility))164 .route("/settings/emails/visibility", post(pages::email_visibility))
163 .route("/settings/password", post(pages::account_password))165 .route("/settings/password", post(pages::account_password))
164 .route(166 .route(
crates/web/src/pages.rs +109 −4
@@ -765,6 +765,13 @@ pub async fn register_submit(
765 Err(err) => return AppError::from(err).into_response(),765 Err(err) => return AppError::from(err).into_response(),
766 };766 };
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
768 match open_session(&state, &jar, &user, &headers).await {775 match open_session(&state, &jar, &user, &headers).await {
769 Ok(jar) => (jar, Redirect::to("/")).into_response(),776 Ok(jar) => (jar, Redirect::to("/")).into_response(),
770 Err(err) => err.into_response(),777 Err(err) => err.into_response(),
@@ -1137,7 +1144,11 @@ pub async fn email_add(
1137 return AppError::BadRequest("Enter a valid email address.".to_string()).into_response();1144 return AppError::BadRequest("Enter a valid email address.".to_string()).into_response();
1138 }1145 }
1139 match state.store.add_email(&user.id, email).await {1146 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 }
1141 Err(store::StoreError::Conflict { .. }) => {1152 Err(store::StoreError::Conflict { .. }) => {
1142 AppError::BadRequest("That email is already in use.".to_string()).into_response()1153 AppError::BadRequest("That email is already in use.".to_string()).into_response()
1143 }1154 }
@@ -1145,6 +1156,80 @@ pub async fn email_add(
1145 }1156 }
1146}1157}
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).
1161async 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.
1189pub 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.
1217pub 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
1148/// An email-id form (set-primary or delete).1233/// An email-id form (set-primary or delete).
1149#[derive(Debug, Deserialize)]1234#[derive(Debug, Deserialize)]
1150pub struct EmailIdForm {1235pub struct EmailIdForm {
@@ -1167,8 +1252,14 @@ pub async fn email_primary(
1167 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {1252 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1168 return AppError::Forbidden.into_response();1253 return AppError::Forbidden.into_response();
1169 }1254 }
1170 let _ = state.store.set_primary_email(&user.id, &form.id).await;1255 match state.store.set_primary_email(&user.id, &form.id).await {
1171 Redirect::to("/settings/account").into_response()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 }
1172}1263}
11731264
1174/// `POST /settings/emails/delete` — remove one of the viewer's secondary emails.1265/// `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
1642 span class="entry-row-title" {1733 span class="entry-row-title" {
1643 (e.email)1734 (e.email)
1644 @if e.is_primary { " " span class="badge" { "primary" } }1735 @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 }
1645 }1741 }
1646 }1742 }
1647 div class="entry-row-actions" {1743 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() {
1649 form method="post" action="/settings/emails/primary" {1752 form method="post" action="/settings/emails/primary" {
1650 input type="hidden" name="_csrf" value=(csrf);1753 input type="hidden" name="_csrf" value=(csrf);
1651 input type="hidden" name="id" value=(e.id);1754 input type="hidden" name="id" value=(e.id);
1652 button class="btn" type="submit" { "Make primary" }1755 button class="btn" type="submit" { "Make primary" }
1653 }1756 }
1757 }
1758 @if !e.is_primary {
1654 form method="post" action="/settings/emails/delete" {1759 form method="post" action="/settings/emails/delete" {
1655 input type="hidden" name="_csrf" value=(csrf);1760 input type="hidden" name="_csrf" value=(csrf);
1656 input type="hidden" name="id" value=(e.id);1761 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() {
522 .find(|e| e.email == "second@example.com")522 .find(|e| e.email == "second@example.com")
523 .unwrap();523 .unwrap();
524 assert!(!second.is_primary);524 assert!(!second.is_primary);
525 assert!(!second.verified(), "added emails start unverified");
525 let second_id = second.id.clone();526 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.
528 let res = post(557 let res = post(
529 "/settings/emails/primary",558 "/settings/emails/primary",
530 format!("id={second_id}&_csrf={token}"),559 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).
4ALTER TABLE user_emails ADD COLUMN verified_at BIGINT;
5UPDATE user_emails SET verified_at = created_at;
6
7CREATE 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);
13CREATE 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).
4ALTER TABLE user_emails ADD COLUMN verified_at BIGINT;
5UPDATE user_emails SET verified_at = created_at;
6
7CREATE 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);
13CREATE INDEX idx_email_tokens_email ON email_tokens (email_id);