g1t/services/identity/src/emails.rs
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1 | //! A person's email addresses: adding, confirming, choosing the primary and |
| 2 | //! the backup, removing, keeping them private, and finding whose an address | |
| 3 | //! is. | |
| 4 | //! | |
| 5 | //! `user_emails` holds every address. `users.primary_email_id` names the | |
| 6 | //! primary, and `users.email` and `users.email_verified_at` are kept as a | |
| 7 | //! copy of it (other code reads them, and "the account is confirmed" means | |
| 8 | //! its primary is). Every change to the primary here writes all three. | |
| 9 | //! | |
| 10 | //! A confirmed address belongs to one account: a unique index on confirmed | |
| 11 | //! rows makes sure of it. Unconfirmed rows may repeat across accounts; the | |
| 12 | //! first account to follow its confirmation link keeps the address, in one | |
| 13 | //! transaction that confirms its row only if nobody else's is confirmed, | |
| 14 | //! and then drops everyone else's unconfirmed row for it. An account whose | |
| 15 | //! primary was dropped that way (it never confirmed it) is left without one | |
| 16 | //! until it confirms another address, which becomes primary on its own. | |
| 17 | //! | |
| 18 | //! Sensitive changes (adding, removing, primary, backup) need proof that it | |
| 19 | //! is the person (`security.rs`), are written to their security log, are | |
| 20 | //! announced as `user.email_*` events, and are told to every confirmed | |
| 21 | //! address, the removed one included. | |
| 22 | ||
| 23 | use std::collections::HashMap; | |
| 24 | ||
| 25 | use g1t_contracts::accounts::*; | |
| 26 | use g1t_contracts::events::UserEmailChanged; | |
| 27 | use g1t_contracts::identity::{UserArgs, UsernameArgs}; | |
| 28 | use g1t_contracts::time::{SQL_NOW, rfc3339, sql_after}; | |
| 29 | use g1t_contracts::{FailureCode, Outcome, new_id}; | |
| 30 | use g1t_kit::now_ms; | |
| 31 | use serde::Deserialize; | |
| 32 | use worker::Result; | |
| 33 | use worker::wasm_bindgen::JsValue; | |
| 34 | ||
| 35 | use crate::security::{PEOPLE_ONLY, is_person}; | |
| 36 | use crate::throttle::CONFIRM_ACCOUNT; | |
| 37 | use crate::{Identity, VERIFY_TTL_SECONDS, crypto, email}; | |
| 38 | ||
| 39 | /// One row of `user_emails`. | |
| 40 | #[derive(Clone, Debug, Deserialize)] | |
| 41 | pub struct EmailRow { | |
| 42 | pub id: String, | |
| 43 | pub email: String, | |
| 44 | pub display: String, | |
| 45 | pub verified_at: Option<String>, | |
| 46 | pub sent_at: Option<String>, | |
| 47 | pub created_at: String, | |
| 48 | } | |
| 49 | ||
| 50 | /// What `users` says about a person's addresses. | |
| 51 | #[derive(Debug, Deserialize)] | |
| 52 | struct AccountRow { | |
| 53 | id: String, | |
| 54 | username: String, | |
| 55 | primary_email_id: Option<String>, | |
| 56 | backup_email_id: Option<String>, | |
| 57 | private_email: u8, | |
| 58 | block_private_pushes: u8, | |
| 59 | #[serde(default)] | |
| 60 | created_at: String, | |
| 61 | } | |
| 62 | ||
| 63 | const ACCOUNT_COLUMNS: &str = | |
| 64 | "id, username, primary_email_id, backup_email_id, private_email, block_private_pushes, created_at"; | |
| 65 | ||
| 66 | /// The order addresses are shown in: the primary, confirmed ones, the rest; | |
| 67 | /// oldest first within each. | |
| 68 | fn sorted(mut rows: Vec<EmailRow>, primary: Option<&str>) -> Vec<EmailRow> { | |
| 69 | rows.sort_by(|a, b| { | |
| 70 | let rank = |row: &EmailRow| (Some(row.id.as_str()) != primary, row.verified_at.is_none()); | |
| 71 | rank(a).cmp(&rank(b)).then_with(|| a.created_at.cmp(&b.created_at)).then_with(|| a.id.cmp(&b.id)) | |
| 72 | }); | |
| 73 | rows | |
| 74 | } | |
| 75 | ||
| 76 | fn states(rows: &[EmailRow], primary: Option<&str>) -> Vec<EmailState> { | |
| 77 | rows.iter() | |
| 78 | .map(|row| EmailState { | |
| 79 | email: row.email.clone(), | |
| 80 | verified: row.verified_at.is_some(), | |
| 81 | primary: Some(row.id.as_str()) == primary, | |
| 82 | }) | |
| 83 | .collect() | |
| 84 | } | |
| 85 | ||
| 86 | /// The address commits g1t makes for a person carry: their noreply address | |
| 87 | /// while they keep their address private or have no confirmed primary. | |
| 88 | fn commit_email(private: bool, primary: Option<&EmailRow>, noreply: &str) -> String { | |
| 89 | match primary { | |
| 90 | Some(row) if !private && row.verified_at.is_some() => row.email.clone(), | |
| 91 | _ => noreply.to_owned(), | |
| 92 | } | |
| 93 | } | |
| 94 | ||
| 95 | fn view(account: &AccountRow, rows: Vec<EmailRow>) -> AccountEmails { | |
| 96 | let primary = account.primary_email_id.as_deref(); | |
| 97 | let rows = sorted(rows, primary); | |
| 98 | let noreply = noreply_address(&account.id, &account.username); | |
| 99 | let private = account.private_email != 0; | |
| 100 | let commit = commit_email(private, rows.iter().find(|row| Some(row.id.as_str()) == primary), &noreply); | |
| 101 | AccountEmails { | |
| 102 | emails: rows | |
| 103 | .into_iter() | |
| 104 | .map(|row| AccountEmail { | |
| 105 | primary: Some(row.id.as_str()) == primary, | |
| 106 | backup: Some(row.id.as_str()) == account.backup_email_id.as_deref(), | |
| 107 | verified: row.verified_at.is_some(), | |
| 108 | email: row.display, | |
| 109 | created_at: row.created_at, | |
| 110 | verified_at: row.verified_at, | |
| 111 | }) | |
| 112 | .collect(), | |
| 113 | private_email: private, | |
| 114 | block_private_pushes: account.block_private_pushes != 0, | |
| 115 | noreply, | |
| 116 | commit_email: commit, | |
| 117 | limit: MAX_EMAILS as u32, | |
| 118 | } | |
| 119 | } | |
| 120 | ||
| 121 | /// Whether pushes by this person are checked for their addresses: only | |
| 122 | /// while the address is private and they asked for pushes to be blocked. | |
| 123 | fn guards_pushes(account: &AccountRow) -> bool { | |
| 124 | account.private_email != 0 && account.block_private_pushes != 0 | |
| 125 | } | |
| 126 | ||
| 127 | /// Whether a confirmation link may be sent again to an address last sent | |
| 128 | /// one at `sent_at`, at `now_ms`. | |
| 129 | pub fn may_resend(sent_at: Option<&str>, now_ms: u64) -> bool { | |
| 130 | !is_recent(sent_at, now_ms, RESEND_SECONDS) | |
| 131 | } | |
| 132 | ||
| 133 | impl Identity { | |
| 134 | async fn account_row(&self, user_id: &str) -> Result<Option<AccountRow>> { | |
| 135 | self.db | |
| 136 | .prepare(format!("SELECT {ACCOUNT_COLUMNS} FROM users WHERE id = ?")) | |
| 137 | .bind(&[user_id.into()])? | |
| 138 | .first::<AccountRow>(None) | |
| 139 | .await | |
| 140 | } | |
| 141 | ||
| 142 | pub async fn email_rows(&self, user_id: &str) -> Result<Vec<EmailRow>> { | |
| 143 | self.db | |
| 144 | .prepare( | |
| 145 | "SELECT id, email, display, verified_at, sent_at, created_at FROM user_emails | |
| 146 | WHERE user_id = ? ORDER BY created_at, id", | |
| 147 | ) | |
| 148 | .bind(&[user_id.into()])? | |
| 149 | .all() | |
| 150 | .await? | |
| 151 | .results::<EmailRow>() | |
| 152 | } | |
| 153 | ||
| 154 | async fn emails_view(&self, user_id: &str) -> Result<Outcome<AccountEmails>> { | |
| 155 | let Some(account) = self.account_row(user_id).await? else { | |
| 156 | return Ok(Outcome::fail(FailureCode::NotFound, "No such account.")); | |
| 157 | }; | |
| 158 | let rows = self.email_rows(user_id).await?; | |
| 159 | Ok(Outcome::Ok(view(&account, rows))) | |
| 160 | } | |
| 161 | ||
| 162 | /// A person's confirmed addresses, lowercased, the primary first. | |
| 163 | pub async fn verified_emails(&self, user_id: &str) -> Result<Vec<String>> { | |
| 164 | let account = self.account_row(user_id).await?; | |
| 165 | let primary = account.as_ref().and_then(|account| account.primary_email_id.as_deref()); | |
| 166 | Ok(sorted(self.email_rows(user_id).await?, primary) | |
| 167 | .into_iter() | |
| 168 | .filter(|row| row.verified_at.is_some()) | |
| 169 | .map(|row| row.email) | |
| 170 | .collect()) | |
| 171 | } | |
| 172 | ||
| 173 | /// The account that has confirmed `email`, by id. The one helper every | |
| 174 | /// "does this address belong to someone" question goes through (signing | |
| 175 | /// in with GitHub, invites, password resets, signing in by email). | |
| 176 | pub async fn user_with_verified_email(&self, email: &str) -> Result<Option<String>> { | |
| 177 | #[derive(Deserialize)] | |
| 178 | struct Owner { | |
| 179 | user_id: String, | |
| 180 | } | |
| 181 | let Some(email) = normalize_email(email) else { | |
| 182 | return Ok(None); | |
| 183 | }; | |
| 184 | Ok(self | |
| 185 | .db | |
| 186 | .prepare("SELECT user_id FROM user_emails WHERE email = ? AND verified_at IS NOT NULL") | |
| 187 | .bind(&[email.as_str().into()])? | |
| 188 | .first::<Owner>(None) | |
| 189 | .await? | |
| 190 | .map(|owner| owner.user_id)) | |
| 191 | } | |
| 192 | ||
| 193 | /// Whether `email` is any account's: confirmed, or the unconfirmed | |
| 194 | /// primary a new account signed up with. | |
| 195 | pub async fn email_in_use(&self, email: &str) -> Result<bool> { | |
| 196 | let Some(email) = normalize_email(email) else { | |
| 197 | return Ok(false); | |
| 198 | }; | |
| 199 | let found = self | |
| 200 | .db | |
| 201 | .prepare( | |
| 202 | "SELECT e.id FROM user_emails e JOIN users u ON u.id = e.user_id | |
| 203 | WHERE e.email = ?1 AND (e.verified_at IS NOT NULL OR u.primary_email_id = e.id) LIMIT 1", | |
| 204 | ) | |
| 205 | .bind(&[email.as_str().into()])? | |
| 206 | .first::<serde_json::Value>(None) | |
| 207 | .await?; | |
| 208 | Ok(found.is_some()) | |
| 209 | } | |
| 210 | ||
| 211 | /// `list_emails`. | |
| 212 | pub async fn list_emails(&self, a: UserArgs) -> Result<Outcome<AccountEmails>> { | |
| 213 | if !is_person(&a.user) { | |
| 214 | return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY)); | |
| 215 | } | |
| 216 | self.emails_view(&a.user.id).await | |
| 217 | } | |
| 218 | ||
| 219 | /// Stores a confirmation link for one address and emails it. | |
| 220 | async fn send_address_link(&self, user_id: &str, username: &str, row: &EmailRow) -> Result<()> { | |
| 221 | let token = crypto::random_hex(32); | |
| 222 | self.db | |
| 223 | .batch(vec![ | |
| 224 | self.db | |
| 225 | .prepare(format!( | |
| 226 | "INSERT INTO email_tokens (id, user_id, kind, expires_at, email_id) | |
| 227 | VALUES (?, ?, 'verify', {}, ?)", | |
| 228 | sql_after(VERIFY_TTL_SECONDS) | |
| 229 | )) | |
| 230 | .bind(&[crypto::sha256_hex(&token).into(), user_id.into(), row.id.as_str().into()])?, | |
| 231 | self.db | |
| 232 | .prepare(format!("UPDATE user_emails SET sent_at = {SQL_NOW} WHERE id = ?")) | |
| 233 | .bind(&[row.id.as_str().into()])?, | |
| 234 | ]) | |
| 235 | .await?; | |
| 236 | email::send_added_address(&self.env, &row.display, username, &token).await | |
| 237 | } | |
| 238 | ||
| 239 | /// `add_email`. | |
| 240 | pub async fn add_email(&self, a: AccountEmailArgs) -> Result<Outcome<AccountEmails>> { | |
| 241 | if !is_person(&a.user) { | |
| 242 | return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY)); | |
| 243 | } | |
| 244 | let typed = a.email.trim(); | |
| 245 | let Some(email) = normalize_email(typed) else { | |
| 246 | return Ok(Outcome::fail(FailureCode::Invalid, "Enter a valid email address.")); | |
| 247 | }; | |
| 248 | if parse_noreply(&email).is_some() || email.ends_with("@users.g1t.sh") { | |
| 249 | return Ok(Outcome::fail(FailureCode::Invalid, "That is a g1t noreply address; add an address you receive mail at.")); | |
| 250 | } | |
| 251 | if let Some(refusal) = self.proof(&a.user.id, &a.reauth).await?.refusal() { | |
| 252 | return Ok(refusal); | |
| 253 | } | |
| 254 | let rows = self.email_rows(&a.user.id).await?; | |
| 255 | if let Some(row) = rows.iter().find(|row| row.email == email) { | |
| 256 | if row.verified_at.is_some() { | |
| 257 | return Ok(Outcome::fail(FailureCode::Conflict, "That address is already on your account.")); | |
| 258 | } | |
| 259 | // Added before and not confirmed: adding it again sends the link again. | |
| 260 | if may_resend(row.sent_at.as_deref(), now_ms()) && self.allow(CONFIRM_ACCOUNT, &a.user.id).await? { | |
| 261 | self.send_address_link(&a.user.id, &a.user.username, row).await?; | |
| 262 | } | |
| 263 | return self.emails_view(&a.user.id).await; | |
| 264 | } | |
| 265 | if rows.len() >= MAX_EMAILS { | |
| 266 | return Ok(Outcome::fail( | |
| 267 | FailureCode::Invalid, | |
| 268 | format!("An account can have {MAX_EMAILS} addresses. Remove one first."), | |
| 269 | )); | |
| 270 | } | |
| 271 | if self.user_with_verified_email(&email).await?.is_some() { | |
| 272 | return Ok(Outcome::fail(FailureCode::Conflict, "That address is confirmed on another g1t account.")); | |
| 273 | } | |
| 274 | let now = now_ms(); | |
| 275 | let row = EmailRow { | |
| 276 | id: new_id("eml", now), | |
| 277 | email: email.clone(), | |
| 278 | display: typed.to_owned(), | |
| 279 | verified_at: None, | |
| 280 | sent_at: None, | |
| 281 | created_at: rfc3339(now), | |
| 282 | }; | |
| 283 | let inserted = self | |
| 284 | .db | |
| 285 | .prepare( | |
| 286 | "INSERT INTO user_emails (id, user_id, email, display, created_at) | |
| 287 | SELECT ?1, ?2, ?3, ?4, ?5 | |
| 288 | WHERE (SELECT count(*) FROM user_emails WHERE user_id = ?2) < ?6", | |
| 289 | ) | |
| 290 | .bind(&[ | |
| 291 | row.id.as_str().into(), | |
| 292 | a.user.id.as_str().into(), | |
| 293 | row.email.as_str().into(), | |
| 294 | row.display.as_str().into(), | |
| 295 | row.created_at.as_str().into(), | |
| 296 | (MAX_EMAILS as f64).into(), | |
| 297 | ])? | |
| 298 | .run() | |
| 299 | .await; | |
| 300 | if let Err(error) = inserted { | |
| 301 | // The same address added twice at once. | |
| 302 | if error.to_string().contains("UNIQUE") { | |
| 303 | return self.emails_view(&a.user.id).await; | |
| 304 | } | |
| 305 | return Err(error); | |
| 306 | } | |
| 307 | if !self.allow(CONFIRM_ACCOUNT, &a.user.id).await? { | |
| 308 | worker::console_log!("confirmation email held back: too many this hour"); | |
| 309 | } else if let Err(error) = self.send_address_link(&a.user.id, &a.user.username, &row).await { | |
| 310 | worker::console_error!("confirmation email failed: {error}"); | |
| 311 | } | |
| 312 | self.log_security(&a.user.id, "email_added", Some(&row.display), None).await; | |
| 313 | self.tell_addresses(&a.user.id, &a.user.username, &format!("{} was added", row.display), None).await; | |
| 314 | self.announce_email("user.email_added", &a.user.id, false).await; | |
| 315 | self.emails_view(&a.user.id).await | |
| 316 | } | |
| 317 | ||
| 318 | /// `resend_email_verification`. | |
| 319 | pub async fn resend_email_verification(&self, a: AccountEmailArgs) -> Result<Outcome<bool>> { | |
| 320 | if !is_person(&a.user) { | |
| 321 | return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY)); | |
| 322 | } | |
| 323 | let email = normalize_email(&a.email).unwrap_or_default(); | |
| 324 | let rows = self.email_rows(&a.user.id).await?; | |
| 325 | let Some(row) = rows.iter().find(|row| row.email == email) else { | |
| 326 | return Ok(Outcome::fail(FailureCode::NotFound, "That address is not on your account.")); | |
| 327 | }; | |
| 328 | if row.verified_at.is_some() { | |
| 329 | return Ok(Outcome::fail(FailureCode::Conflict, "That address is already confirmed.")); | |
| 330 | } | |
| 331 | if !may_resend(row.sent_at.as_deref(), now_ms()) { | |
| 332 | return Ok(Outcome::fail(FailureCode::Conflict, "A link was sent less than a minute ago. Check your inbox, then try again.")); | |
| 333 | } | |
| 334 | if !self.allow(CONFIRM_ACCOUNT, &a.user.id).await? { | |
| 335 | return Ok(Outcome::fail(FailureCode::Conflict, "Too many confirmation emails this hour. Check your inbox, or try again later.")); | |
| 336 | } | |
| 337 | self.send_address_link(&a.user.id, &a.user.username, row).await?; | |
| 338 | Ok(Outcome::Ok(true)) | |
| 339 | } | |
| 340 | ||
| 341 | /// The banner's "resend": the link for the primary of an account that | |
| 342 | /// has not confirmed it, or for its oldest unconfirmed address when a | |
| 343 | /// confirmed account elsewhere took its primary. | |
| 344 | pub async fn resend_primary(&self, user: &g1t_contracts::User) -> Result<Outcome<bool>> { | |
| 345 | let Some(account) = self.account_row(&user.id).await? else { | |
| 346 | return Ok(Outcome::fail(FailureCode::NotFound, "No such account.")); | |
| 347 | }; | |
| 348 | let rows = sorted(self.email_rows(&user.id).await?, account.primary_email_id.as_deref()); | |
| 349 | if rows.iter().any(|row| Some(row.id.as_str()) == account.primary_email_id.as_deref() && row.verified_at.is_some()) { | |
| 350 | return Ok(Outcome::fail(FailureCode::Conflict, "This account's email is already confirmed.")); | |
| 351 | } | |
| 352 | let Some(row) = rows.iter().find(|row| row.verified_at.is_none()) else { | |
| 353 | return Ok(Outcome::fail(FailureCode::Conflict, "Add an email address in your settings first.")); | |
| 354 | }; | |
| 355 | if !may_resend(row.sent_at.as_deref(), now_ms()) { | |
| 356 | return Ok(Outcome::Ok(true)); | |
| 357 | } | |
| 358 | let token = crypto::random_hex(32); | |
| 359 | self.db | |
| 360 | .batch(vec![ | |
| 361 | self.db | |
| 362 | .prepare(format!( | |
| 363 | "INSERT INTO email_tokens (id, user_id, kind, expires_at, email_id) | |
| 364 | VALUES (?, ?, 'verify', {}, ?)", | |
| 365 | sql_after(VERIFY_TTL_SECONDS) | |
| 366 | )) | |
| 367 | .bind(&[crypto::sha256_hex(&token).into(), user.id.as_str().into(), row.id.as_str().into()])?, | |
| 368 | self.db | |
| 369 | .prepare(format!("UPDATE user_emails SET sent_at = {SQL_NOW} WHERE id = ?")) | |
| 370 | .bind(&[row.id.as_str().into()])?, | |
| 371 | ]) | |
| 372 | .await?; | |
| 373 | email::send_verification(&self.env, &row.display, &account.username, &token).await?; | |
| 374 | Ok(Outcome::Ok(true)) | |
| 375 | } | |
| 376 | ||
| 377 | /// Confirms an address after its link was followed: `email_id`, or the | |
| 378 | /// primary for links sent before addresses had ids. Returns the address | |
| 379 | /// confirmed, or why it could not be. | |
| 380 | pub async fn confirm_address(&self, user_id: &str, email_id: Option<&str>) -> Result<Outcome<String>> { | |
| 381 | let Some(account) = self.account_row(user_id).await? else { | |
| 382 | return Ok(Outcome::fail(FailureCode::Invalid, "This confirmation link is not valid or has expired.")); | |
| 383 | }; | |
| 384 | let Some(email_id) = email_id.map(str::to_owned).or(account.primary_email_id.clone()) else { | |
| 385 | return Ok(Outcome::fail(FailureCode::Invalid, "This confirmation link is not valid or has expired.")); | |
| 386 | }; | |
| 387 | let rows = self.email_rows(user_id).await?; | |
| 388 | let Some(row) = rows.into_iter().find(|row| row.id == email_id) else { | |
| 389 | return Ok(Outcome::fail( | |
| 390 | FailureCode::Conflict, | |
| 391 | "That address was confirmed by another g1t account first, or was removed from yours.", | |
| 392 | )); | |
| 393 | }; | |
| 394 | if row.verified_at.is_some() { | |
| 395 | return Ok(Outcome::Ok(row.display)); | |
| 396 | } | |
| 397 | let won = |sql: &str| sql.replace("{WON}", "EXISTS (SELECT 1 FROM user_emails WHERE id = ?1 AND verified_at IS NOT NULL)"); | |
| 398 | let id = JsValue::from(row.id.as_str()); | |
| 399 | let user = JsValue::from(user_id); | |
| 400 | let address = JsValue::from(row.email.as_str()); | |
| 401 | // One transaction. Confirm this row only if no account has the | |
| 402 | // address confirmed; then, only if it was, drop everyone else's | |
| 403 | // claim to it, and make it this account's primary when the account | |
| 404 | // has no confirmed primary. | |
| 405 | self.db | |
| 406 | .batch(vec![ | |
| 407 | self.db | |
| 408 | .prepare(format!( | |
| 409 | "UPDATE user_emails SET verified_at = {SQL_NOW} | |
| 410 | WHERE id = ?1 AND verified_at IS NULL | |
| 411 | AND NOT EXISTS (SELECT 1 FROM user_emails WHERE email = ?2 AND verified_at IS NOT NULL)" | |
| 412 | )) | |
| 413 | .bind(&[id.clone(), address.clone()])?, | |
| 414 | self.db | |
| 415 | .prepare(won( | |
| 416 | "UPDATE users SET email = NULL, email_verified_at = NULL, primary_email_id = NULL | |
| 417 | WHERE id <> ?3 AND {WON} AND primary_email_id IN ( | |
| 418 | SELECT id FROM user_emails WHERE email = ?2 AND verified_at IS NULL AND user_id <> ?3)", | |
| 419 | )) | |
| 420 | .bind(&[id.clone(), address.clone(), user.clone()])?, | |
| 421 | self.db | |
| 422 | .prepare(won( | |
| 423 | "UPDATE users SET backup_email_id = NULL | |
| 424 | WHERE id <> ?3 AND {WON} AND backup_email_id IN ( | |
| 425 | SELECT id FROM user_emails WHERE email = ?2 AND verified_at IS NULL AND user_id <> ?3)", | |
| 426 | )) | |
| 427 | .bind(&[id.clone(), address.clone(), user.clone()])?, | |
| 428 | self.db | |
| 429 | .prepare(won( | |
| 430 | "DELETE FROM user_emails WHERE email = ?2 AND verified_at IS NULL AND user_id <> ?3 AND {WON}", | |
| 431 | )) | |
| 432 | .bind(&[id.clone(), address.clone(), user.clone()])?, | |
| 433 | self.db | |
| 434 | .prepare(won( | |
| 435 | "UPDATE users SET primary_email_id = ?1, email = ?2, | |
| 436 | email_verified_at = (SELECT verified_at FROM user_emails WHERE id = ?1) | |
| 437 | WHERE id = ?3 AND {WON} AND ( | |
| 438 | primary_email_id IS NULL OR primary_email_id = ?1 | |
| 439 | OR NOT EXISTS (SELECT 1 FROM user_emails WHERE id = users.primary_email_id AND verified_at IS NOT NULL))", | |
| 440 | )) | |
| 441 | .bind(&[id.clone(), address.clone(), user.clone()])?, | |
| 442 | ]) | |
| 443 | .await?; | |
| 444 | let confirmed = self | |
| 445 | .email_rows(user_id) | |
| 446 | .await? | |
| 447 | .into_iter() | |
| 448 | .any(|current| current.id == row.id && current.verified_at.is_some()); | |
| 449 | if !confirmed { | |
| 450 | return Ok(Outcome::fail( | |
| 451 | FailureCode::Conflict, | |
| 452 | "That address was confirmed by another g1t account first. Use a different address.", | |
| 453 | )); | |
| 454 | } | |
| 455 | self.log_security(user_id, "email_verified", Some(&row.display), None).await; | |
| 456 | self.announce_email("user.email_verified", user_id, false).await; | |
| 457 | Ok(Outcome::Ok(row.display)) | |
| 458 | } | |
| 459 | ||
| 460 | /// `remove_email`. | |
| 461 | pub async fn remove_email(&self, a: AccountEmailArgs) -> Result<Outcome<AccountEmails>> { | |
| 462 | if !is_person(&a.user) { | |
| 463 | return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY)); | |
| 464 | } | |
| 465 | let email = normalize_email(&a.email).unwrap_or_default(); | |
| 466 | let Some(account) = self.account_row(&a.user.id).await? else { | |
| 467 | return Ok(Outcome::fail(FailureCode::NotFound, "No such account.")); | |
| 468 | }; | |
| 469 | let rows = self.email_rows(&a.user.id).await?; | |
| 470 | if let Some(why) = removal_refusal(&states(&rows, account.primary_email_id.as_deref()), &email) { | |
| 471 | return Ok(Outcome::fail(FailureCode::Conflict, why)); | |
| 472 | } | |
| 473 | if let Some(refusal) = self.proof(&a.user.id, &a.reauth).await?.refusal() { | |
| 474 | return Ok(refusal); | |
| 475 | } | |
| 476 | let Some(row) = rows.into_iter().find(|row| row.email == email) else { | |
| 477 | return self.emails_view(&a.user.id).await; | |
| 478 | }; | |
| 479 | self.delete_address(&a.user.id, &row).await?; | |
| 480 | self.log_security(&a.user.id, "email_removed", Some(&row.display), None).await; | |
| 481 | let removed = row.verified_at.is_some().then_some(row.display.as_str()); | |
| 482 | self.tell_addresses(&a.user.id, &a.user.username, &format!("{} was removed", row.display), removed).await; | |
| 483 | self.announce_email("user.email_removed", &a.user.id, false).await; | |
| 484 | self.emails_view(&a.user.id).await | |
| 485 | } | |
| 486 | ||
| 487 | /// Deletes an address and anything pointing at it. The caller has made | |
| 488 | /// sure it is not the primary, or has moved the primary already. | |
| 489 | async fn delete_address(&self, user_id: &str, row: &EmailRow) -> Result<()> { | |
| 490 | self.db | |
| 491 | .batch(vec![ | |
| 492 | self.db | |
| 493 | .prepare("UPDATE users SET backup_email_id = NULL WHERE id = ? AND backup_email_id = ?") | |
| 494 | .bind(&[user_id.into(), row.id.as_str().into()])?, | |
| 495 | self.db | |
| 496 | .prepare("DELETE FROM email_tokens WHERE user_id = ? AND email_id = ?") | |
| 497 | .bind(&[user_id.into(), row.id.as_str().into()])?, | |
| 498 | self.db | |
| 499 | .prepare("DELETE FROM user_emails WHERE id = ? AND user_id = ?") | |
| 500 | .bind(&[row.id.as_str().into(), user_id.into()])?, | |
| 501 | ]) | |
| 502 | .await?; | |
| 503 | Ok(()) | |
| 504 | } | |
| 505 | ||
| 506 | /// Makes a confirmed address the primary, keeping `users` in step. | |
| 507 | async fn set_primary(&self, user_id: &str, row: &EmailRow) -> Result<()> { | |
| 508 | self.db | |
| 509 | .prepare( | |
| 510 | "UPDATE users SET primary_email_id = ?1, email = ?2, | |
| 511 | email_verified_at = (SELECT verified_at FROM user_emails WHERE id = ?1), | |
| 512 | backup_email_id = CASE WHEN backup_email_id = ?1 THEN NULL ELSE backup_email_id END | |
| 513 | WHERE id = ?3 AND EXISTS (SELECT 1 FROM user_emails WHERE id = ?1 AND user_id = ?3 AND verified_at IS NOT NULL)", | |
| 514 | ) | |
| 515 | .bind(&[row.id.as_str().into(), row.email.as_str().into(), user_id.into()])? | |
| 516 | .run() | |
| 517 | .await?; | |
| 518 | Ok(()) | |
| 519 | } | |
| 520 | ||
| 521 | /// `update_email_settings`. | |
| 522 | pub async fn update_email_settings(&self, a: EmailSettingsArgs) -> Result<Outcome<AccountEmails>> { | |
| 523 | if !is_person(&a.user) { | |
| 524 | return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY)); | |
| 525 | } | |
| 526 | let Some(account) = self.account_row(&a.user.id).await? else { | |
| 527 | return Ok(Outcome::fail(FailureCode::NotFound, "No such account.")); | |
| 528 | }; | |
| 529 | let rows = self.email_rows(&a.user.id).await?; | |
| 530 | let find = |email: &str| { | |
| 531 | let email = normalize_email(email).unwrap_or_default(); | |
| 532 | rows.iter().find(|row| row.email == email).cloned() | |
| 533 | }; | |
| 534 | let primary = match a.primary.as_deref().map(str::trim).filter(|email| !email.is_empty()) { | |
| 535 | None => None, | |
| 536 | Some(email) => { | |
| 537 | let normalized = normalize_email(email).unwrap_or_default(); | |
| 538 | if let Some(why) = primary_refusal(&states(&rows, account.primary_email_id.as_deref()), &normalized) { | |
| 539 | return Ok(Outcome::fail(FailureCode::Conflict, why)); | |
| 540 | } | |
| 541 | find(email).filter(|row| Some(row.id.as_str()) != account.primary_email_id.as_deref()) | |
| 542 | } | |
| 543 | }; | |
| 544 | // Some(None): the primary only. | |
| 545 | let backup: Option<Option<EmailRow>> = match a.backup.as_deref().map(str::trim) { | |
| 546 | None => None, | |
| 547 | Some("") => (account.backup_email_id.is_some()).then_some(None), | |
| 548 | Some(email) => { | |
| 549 | let Some(row) = find(email).filter(|row| row.verified_at.is_some()) else { | |
| 550 | return Ok(Outcome::fail(FailureCode::Conflict, "Only a confirmed address on your account can be the backup.")); | |
| 551 | }; | |
| 552 | let will_be_primary = primary.as_ref().map_or(account.primary_email_id.clone(), |row| Some(row.id.clone())); | |
| 553 | if Some(&row.id) == will_be_primary.as_ref() { | |
| 554 | return Ok(Outcome::fail(FailureCode::Conflict, "That is your primary address; choose another for the backup.")); | |
| 555 | } | |
| 556 | (account.backup_email_id.as_deref() != Some(row.id.as_str())).then_some(Some(row)) | |
| 557 | } | |
| 558 | }; | |
| 559 | if (primary.is_some() || backup.is_some()) | |
| 560 | && let Some(refusal) = self.proof(&a.user.id, &a.reauth).await?.refusal() | |
| 561 | { | |
| 562 | return Ok(refusal); | |
| 563 | } | |
| 564 | if let Some(row) = &primary { | |
| 565 | let old = rows.iter().find(|old| Some(old.id.as_str()) == account.primary_email_id.as_deref()); | |
| 566 | self.set_primary(&a.user.id, row).await?; | |
| 567 | self.log_security(&a.user.id, "primary_email_changed", Some(&row.display), None).await; | |
| 568 | // Every confirmed address hears of it, the old primary included. | |
| 569 | self.tell_addresses( | |
| 570 | &a.user.id, | |
| 571 | &a.user.username, | |
| 572 | &format!("{} is now the primary address", row.display), | |
| 573 | old.filter(|old| old.verified_at.is_some()).map(|old| old.display.as_str()), | |
| 574 | ) | |
| 575 | .await; | |
| 576 | self.announce_email("user.primary_email_changed", &a.user.id, false).await; | |
| 577 | } | |
| 578 | if let Some(choice) = &backup { | |
| 579 | self.db | |
| 580 | .prepare("UPDATE users SET backup_email_id = ? WHERE id = ?") | |
| 581 | .bind(&[ | |
| 582 | choice.as_ref().map_or(JsValue::NULL, |row| row.id.as_str().into()), | |
| 583 | a.user.id.as_str().into(), | |
| 584 | ])? | |
| 585 | .run() | |
| 586 | .await?; | |
| 587 | let said = choice.as_ref().map_or("primary only".to_owned(), |row| row.display.clone()); | |
| 588 | self.log_security(&a.user.id, "backup_email_changed", Some(&said), None).await; | |
| 589 | let change = match choice { | |
| 590 | Some(row) => format!("{} now gets security notices too", row.display), | |
| 591 | None => "Security notices now go to the primary address only".to_owned(), | |
| 592 | }; | |
| 593 | self.tell_addresses(&a.user.id, &a.user.username, &change, None).await; | |
| 594 | } | |
| 595 | let private = a.private_email.filter(|private| *private != (account.private_email != 0)); | |
| 596 | let block = a.block_private_pushes.filter(|block| *block != (account.block_private_pushes != 0)); | |
| 597 | if private.is_some() || block.is_some() { | |
| 598 | self.db | |
| 599 | .prepare( | |
| 600 | "UPDATE users SET private_email = COALESCE(?, private_email), | |
| 601 | block_private_pushes = COALESCE(?, block_private_pushes) WHERE id = ?", | |
| 602 | ) | |
| 603 | .bind(&[ | |
| 604 | private.map_or(JsValue::NULL, |on| (on as u8 as f64).into()), | |
| 605 | block.map_or(JsValue::NULL, |on| (on as u8 as f64).into()), | |
| 606 | a.user.id.as_str().into(), | |
| 607 | ])? | |
| 608 | .run() | |
| 609 | .await?; | |
| 610 | let mut said = Vec::new(); | |
| 611 | if let Some(on) = private { | |
| 612 | said.push(if on { "address kept private" } else { "address used on web commits" }); | |
| 613 | } | |
| 614 | if let Some(on) = block { | |
| 615 | said.push(if on { "pushes that expose it refused" } else { "pushes that expose it allowed" }); | |
| 616 | } | |
| 617 | self.log_security(&a.user.id, "email_privacy_changed", Some(&said.join("; ")), None).await; | |
| 618 | } | |
| 619 | self.emails_view(&a.user.id).await | |
| 620 | } | |
| 621 | ||
| 622 | /// Who gets a security notice: every confirmed address when `all`, | |
| 623 | /// otherwise the primary and the backup. | |
| 624 | pub async fn notice_recipients(&self, user_id: &str, all: bool) -> Result<Vec<String>> { | |
| 625 | let Some(account) = self.account_row(user_id).await? else { | |
| 626 | return Ok(Vec::new()); | |
| 627 | }; | |
| 628 | let rows = sorted(self.email_rows(user_id).await?, account.primary_email_id.as_deref()); | |
| 629 | Ok(rows | |
| 630 | .into_iter() | |
| 631 | .filter(|row| row.verified_at.is_some()) | |
| 632 | .filter(|row| { | |
| 633 | all || Some(row.id.as_str()) == account.primary_email_id.as_deref() | |
| 634 | || Some(row.id.as_str()) == account.backup_email_id.as_deref() | |
| 635 | }) | |
| 636 | .map(|row| row.display) | |
| 637 | .collect()) | |
| 638 | } | |
| 639 | ||
| 640 | /// Tells every confirmed address of an account, and `also` (an address | |
| 641 | /// that has just left it), what changed. Best effort. | |
| 642 | pub async fn tell_addresses(&self, user_id: &str, username: &str, change: &str, also: Option<&str>) { | |
| 643 | let mut to = self.notice_recipients(user_id, true).await.unwrap_or_default(); | |
| 644 | if let Some(also) = also | |
| 645 | && !to.iter().any(|known| known.eq_ignore_ascii_case(also)) | |
| 646 | { | |
| 647 | to.push(also.to_owned()); | |
| 648 | } | |
| 649 | for address in to { | |
| 650 | if let Err(error) = email::send_security_notice(&self.env, &address, username, change).await { | |
| 651 | worker::console_error!("security notice failed: {error}"); | |
| 652 | } | |
| 653 | } | |
| 654 | } | |
| 655 | ||
| 656 | /// Tells the primary and the backup what changed. Best effort. | |
| 657 | pub async fn tell_primary_and_backup(&self, user_id: &str, username: &str, change: &str) { | |
| 658 | for address in self.notice_recipients(user_id, false).await.unwrap_or_default() { | |
| 659 | if let Err(error) = email::send_security_notice(&self.env, &address, username, change).await { | |
| 660 | worker::console_error!("security notice failed: {error}"); | |
| 661 | } | |
| 662 | } | |
| 663 | } | |
| 664 | ||
| 665 | async fn announce_email(&self, kind: &'static str, user_id: &str, by_staff: bool) { | |
| 666 | let actor = (!by_staff).then_some(user_id); | |
| 667 | self.announce( | |
| 668 | kind, | |
| 669 | actor, | |
| 670 | UserEmailChanged { | |
| 671 | user_id: user_id.to_owned(), | |
| 672 | by_staff, | |
| 673 | }, | |
| 674 | ) | |
| 675 | .await; | |
| 676 | } | |
| 677 | ||
| 678 | // --- Signing in and resetting by any confirmed address --- | |
| 679 | ||
| 680 | /// The account a password reset for `email` goes to, the address it is | |
| 681 | /// sent to and that address's id: a confirmed address, or else the | |
| 682 | /// unconfirmed address a new account signed up with (following the link | |
| 683 | /// confirms it). | |
| 684 | pub async fn reset_target(&self, email: &str) -> Result<Option<ResetTarget>> { | |
| 685 | let Some(email) = normalize_email(email) else { | |
| 686 | return Ok(None); | |
| 687 | }; | |
| 688 | let confirmed = self | |
| 689 | .db | |
| 690 | .prepare( | |
| 691 | "SELECT u.id AS user_id, u.username, e.id AS email_id, e.display FROM user_emails e | |
| 692 | JOIN users u ON u.id = e.user_id WHERE e.email = ? AND e.verified_at IS NOT NULL", | |
| 693 | ) | |
| 694 | .bind(&[email.as_str().into()])? | |
| 695 | .first::<ResetTarget>(None) | |
| 696 | .await?; | |
| 697 | if confirmed.is_some() { | |
| 698 | return Ok(confirmed); | |
| 699 | } | |
| 700 | self.db | |
| 701 | .prepare( | |
| 702 | "SELECT u.id AS user_id, u.username, e.id AS email_id, e.display FROM user_emails e | |
| 703 | JOIN users u ON u.primary_email_id = e.id | |
| 704 | WHERE e.email = ? AND e.verified_at IS NULL ORDER BY u.created_at, u.id LIMIT 1", | |
| 705 | ) | |
| 706 | .bind(&[email.as_str().into()])? | |
| 707 | .first::<ResetTarget>(None) | |
| 708 | .await | |
| 709 | } | |
| 710 | ||
| 711 | // --- Commits --- | |
| 712 | ||
| 713 | /// `email_owners`: whose commits these are, by author address. | |
| 714 | pub async fn email_owners(&self, a: EmailOwnersArgs) -> Result<HashMap<String, EmailOwner>> { | |
| 715 | #[derive(Deserialize)] | |
| 716 | struct Row { | |
| 717 | email: String, | |
| 718 | id: String, | |
| 719 | username: String, | |
| 720 | avatar: Option<String>, | |
| 721 | } | |
| 722 | let mut owners = HashMap::new(); | |
| 723 | let mut plain: Vec<String> = Vec::new(); | |
| 724 | let mut by_name: Vec<(String, Option<String>, String)> = Vec::new(); | |
| 725 | for email in a.emails.iter().take(200) { | |
| 726 | let Some(email) = normalize_email(email) else { continue }; | |
| 727 | if let Some((suffix, username)) = parse_noreply(&email) { | |
| 728 | by_name.push((username, Some(suffix), email)); | |
| 729 | } else if let Some(username) = email.strip_suffix("@users.g1t.sh") { | |
| 730 | // What g1t put on the commits it made before noreply | |
| 731 | // addresses existed. | |
| 732 | by_name.push((username.to_owned(), None, email.clone())); | |
| 733 | } else if !plain.contains(&email) { | |
| 734 | plain.push(email); | |
| 735 | } | |
| 736 | } | |
| 737 | if !plain.is_empty() { | |
| 738 | let marks = vec!["?"; plain.len()].join(", "); | |
| 739 | let bind: Vec<JsValue> = plain.iter().map(|email| email.as_str().into()).collect(); | |
| 740 | let rows = self | |
| 741 | .db | |
| 742 | .prepare(format!( | |
| 743 | "SELECT e.email, u.id, u.username, u.avatar FROM user_emails e JOIN users u ON u.id = e.user_id | |
| 744 | WHERE e.verified_at IS NOT NULL AND e.email IN ({marks})" | |
| 745 | )) | |
| 746 | .bind(&bind)? | |
| 747 | .all() | |
| 748 | .await? | |
| 749 | .results::<Row>()?; | |
| 750 | for row in rows { | |
| 751 | owners.insert(row.email, EmailOwner { id: row.id, username: row.username, avatar: row.avatar }); | |
| 752 | } | |
| 753 | } | |
| 754 | if !by_name.is_empty() { | |
| 755 | let names: Vec<&str> = by_name.iter().map(|(name, _, _)| name.as_str()).collect(); | |
| 756 | let marks = vec!["?"; names.len()].join(", "); | |
| 757 | let bind: Vec<JsValue> = names.iter().map(|name| (*name).into()).collect(); | |
| 758 | let rows = self | |
| 759 | .db | |
| 760 | .prepare(format!( | |
| 761 | "SELECT username AS email, id, username, avatar FROM users WHERE username IN ({marks})" | |
| 762 | )) | |
| 763 | .bind(&bind)? | |
| 764 | .all() | |
| 765 | .await? | |
| 766 | .results::<Row>()?; | |
| 767 | for (username, suffix, email) in by_name { | |
| 768 | if let Some(row) = rows.iter().find(|row| row.username == username) | |
| 769 | && suffix.as_deref().is_none_or(|suffix| id_suffix(&row.id) == suffix) | |
| 770 | { | |
| 771 | owners.insert( | |
| 772 | email, | |
| 773 | EmailOwner { id: row.id.clone(), username: row.username.clone(), avatar: row.avatar.clone() }, | |
| 774 | ); | |
| 775 | } | |
| 776 | } | |
| 777 | } | |
| 778 | Ok(owners) | |
| 779 | } | |
| 780 | ||
| 781 | /// `commit_identity`. | |
| 782 | pub async fn commit_identity(&self, a: CommitIdentityArgs) -> Result<Option<CommitIdentity>> { | |
| 783 | #[derive(Deserialize)] | |
| 784 | struct Row { | |
| 785 | id: String, | |
| 786 | username: String, | |
| 787 | display_name: Option<String>, | |
| 788 | private_email: u8, | |
| 789 | primary: Option<String>, | |
| 790 | verified: u8, | |
| 791 | } | |
| 792 | let row = self | |
| 793 | .db | |
| 794 | .prepare( | |
| 795 | "SELECT u.id, u.username, u.display_name, u.private_email, e.email AS \"primary\", | |
| 796 | e.verified_at IS NOT NULL AS verified | |
| 797 | FROM users u LEFT JOIN user_emails e ON e.id = u.primary_email_id WHERE u.id = ?", | |
| 798 | ) | |
| 799 | .bind(&[a.user_id.as_str().into()])? | |
| 800 | .first::<Row>(None) | |
| 801 | .await?; | |
| 802 | Ok(row.map(|row| { | |
| 803 | let noreply = noreply_address(&row.id, &row.username); | |
| 804 | let email = match row.primary { | |
| 805 | Some(primary) if row.private_email == 0 && row.verified != 0 => primary, | |
| 806 | _ => noreply, | |
| 807 | }; | |
| 808 | let name = row.display_name.filter(|name| !name.trim().is_empty()).unwrap_or(row.username); | |
| 809 | CommitIdentity { name, email } | |
| 810 | })) | |
| 811 | } | |
| 812 | ||
| 813 | /// `push_email_guard`: the addresses a push by this person must not | |
| 814 | /// publish, while they keep their address private and block pushes | |
| 815 | /// that expose it. One read of the account and one of its addresses. | |
| 816 | pub async fn push_email_guard(&self, a: CommitIdentityArgs) -> Result<Option<PushEmailGuard>> { | |
| 817 | let Some(account) = self.account_row(&a.user_id).await? else { | |
| 818 | return Ok(None); | |
| 819 | }; | |
| 820 | if !guards_pushes(&account) { | |
| 821 | return Ok(None); | |
| 822 | } | |
| 823 | let rows = self.email_rows(&a.user_id).await?; | |
| 824 | Ok(Some(PushEmailGuard { | |
| 825 | emails: rows.into_iter().filter(|row| row.verified_at.is_some()).map(|row| row.email.to_lowercase()).collect(), | |
| 826 | noreply: noreply_address(&account.id, &account.username), | |
| 827 | })) | |
| 828 | } | |
| 829 | ||
| 830 | // --- Staff --- | |
| 831 | ||
| 832 | /// `admin_user`. | |
| 833 | pub async fn admin_user(&self, a: UsernameArgs) -> Result<Option<AdminUser>> { | |
| 834 | #[derive(Deserialize)] | |
| 835 | struct Id { | |
| 836 | id: String, | |
| 837 | } | |
| 838 | let found = self | |
| 839 | .db | |
| 840 | .prepare("SELECT id FROM users WHERE username = ?") | |
| 841 | .bind(&[a.username.trim().to_lowercase().into()])? | |
| 842 | .first::<Id>(None) | |
| 843 | .await?; | |
| 844 | let Some(found) = found else { | |
| 845 | return Ok(None); | |
| 846 | }; | |
| 847 | self.admin_user_by_id(&found.id).await | |
| 848 | } | |
| 849 | ||
| 850 | async fn admin_user_by_id(&self, user_id: &str) -> Result<Option<AdminUser>> { | |
| 851 | let Some(account) = self.account_row(user_id).await? else { | |
| 852 | return Ok(None); | |
| 853 | }; | |
| 854 | let rows = self.email_rows(user_id).await?; | |
| 855 | let log = self.security_events(user_id, true).await?; | |
| 856 | let emails = view(&account, rows); | |
| 857 | Ok(Some(AdminUser { | |
| 858 | id: account.id, | |
| 859 | username: account.username, | |
| 860 | created_at: account.created_at, | |
| 861 | emails: emails.emails, | |
| 862 | private_email: emails.private_email, | |
| 863 | log, | |
| 864 | })) | |
| 865 | } | |
| 866 | ||
| 867 | /// `admin_remove_email`. | |
| 868 | pub async fn admin_remove_email(&self, a: AdminRemoveEmailArgs) -> Result<Outcome<AdminUser>> { | |
| 869 | let reason = a.reason.trim(); | |
| 870 | if reason.is_empty() { | |
| 871 | return Ok(Outcome::fail(FailureCode::Invalid, "Say why the address is being removed; the person sees it.")); | |
| 872 | } | |
| 873 | if a.staff.trim().is_empty() { | |
| 874 | return Ok(Outcome::fail(FailureCode::Invalid, "Staff changes name who made them.")); | |
| 875 | } | |
| 876 | let Some(user) = self.admin_user(UsernameArgs { username: a.username.clone() }).await? else { | |
| 877 | return Ok(Outcome::fail(FailureCode::NotFound, "No such account.")); | |
| 878 | }; | |
| 879 | let Some(account) = self.account_row(&user.id).await? else { | |
| 880 | return Ok(Outcome::fail(FailureCode::NotFound, "No such account.")); | |
| 881 | }; | |
| 882 | let email = normalize_email(&a.email).unwrap_or_default(); | |
| 883 | let rows = sorted(self.email_rows(&user.id).await?, account.primary_email_id.as_deref()); | |
| 884 | let Some(row) = rows.iter().find(|row| row.email == email).cloned() else { | |
| 885 | return Ok(Outcome::fail(FailureCode::NotFound, "That address is not on this account.")); | |
| 886 | }; | |
| 887 | let confirmed: Vec<&EmailRow> = rows.iter().filter(|other| other.verified_at.is_some()).collect(); | |
| 888 | if row.verified_at.is_some() && confirmed.len() <= 1 { | |
| 889 | return Ok(Outcome::fail( | |
| 890 | FailureCode::Conflict, | |
| 891 | "That is the account's only confirmed address. The person has to add and confirm another first.", | |
| 892 | )); | |
| 893 | } | |
| 894 | let was_primary = account.primary_email_id.as_deref() == Some(row.id.as_str()); | |
| 895 | if was_primary { | |
| 896 | match confirmed.iter().find(|other| other.id != row.id) { | |
| 897 | Some(next) => self.set_primary(&user.id, next).await?, | |
| 898 | None => { | |
| 899 | // An unconfirmed primary on an account with no | |
| 900 | // confirmed address: it is left without one. | |
| 901 | self.db | |
| 902 | .prepare("UPDATE users SET primary_email_id = NULL, email = NULL, email_verified_at = NULL WHERE id = ?") | |
| 903 | .bind(&[user.id.as_str().into()])? | |
| 904 | .run() | |
| 905 | .await?; | |
| 906 | } | |
| 907 | } | |
| 908 | } | |
| 909 | self.delete_address(&user.id, &row).await?; | |
| 910 | let staff = (a.staff.trim(), reason); | |
| 911 | self.log_security(&user.id, "email_removed", Some(&row.display), Some(staff)).await; | |
| 912 | let removed = row.verified_at.is_some().then_some(row.display.as_str()); | |
| 913 | self.tell_addresses(&user.id, &user.username, &format!("g1t staff removed {} ({reason})", row.display), removed) | |
| 914 | .await; | |
| 915 | self.announce_email("user.email_removed", &user.id, true).await; | |
| 916 | if was_primary { | |
| 917 | self.announce_email("user.primary_email_changed", &user.id, true).await; | |
| 918 | } | |
| 919 | Ok(match self.admin_user_by_id(&user.id).await? { | |
| 920 | Some(user) => Outcome::Ok(user), | |
| 921 | None => Outcome::fail(FailureCode::NotFound, "No such account."), | |
| 922 | }) | |
| 923 | } | |
| 924 | } | |
| 925 | ||
| 926 | /// Where a password reset goes. | |
| 927 | #[derive(Debug, Deserialize)] | |
| 928 | pub struct ResetTarget { | |
| 929 | pub user_id: String, | |
| 930 | pub username: String, | |
| 931 | pub email_id: String, | |
| 932 | pub display: String, | |
| 933 | } | |
| 934 | ||
| 935 | #[cfg(test)] | |
| 936 | mod tests { | |
| 937 | use super::*; | |
| 938 | ||
| 939 | fn row(id: &str, email: &str, verified: bool, created: &str) -> EmailRow { | |
| 940 | EmailRow { | |
| 941 | id: id.into(), | |
| 942 | email: email.into(), | |
| 943 | display: email.to_uppercase(), | |
| 944 | verified_at: verified.then(|| "2026-10-01T00:00:00.000Z".to_owned()), | |
| 945 | sent_at: None, | |
| 946 | created_at: created.into(), | |
| 947 | } | |
| 948 | } | |
| 949 | ||
| 950 | fn account(primary: Option<&str>, backup: Option<&str>, private: bool) -> AccountRow { | |
| 951 | AccountRow { | |
| 952 | id: "usr_01j9zq4m8x7k2v5n3b6c1d0efg".into(), | |
| 953 | username: "ada".into(), | |
| 954 | primary_email_id: primary.map(Into::into), | |
| 955 | backup_email_id: backup.map(Into::into), | |
| 956 | private_email: private as u8, | |
| 957 | block_private_pushes: 0, | |
| 958 | created_at: String::new(), | |
| 959 | } | |
| 960 | } | |
| 961 | ||
| 962 | #[test] | |
| 963 | fn the_primary_comes_first_then_confirmed_then_the_rest() { | |
| 964 | let rows = vec![ | |
| 965 | row("e1", "old@x.io", false, "2026-01-01"), | |
| 966 | row("e2", "work@x.io", true, "2026-02-01"), | |
| 967 | row("e3", "home@x.io", true, "2026-03-01"), | |
| 968 | ]; | |
| 969 | let order: Vec<String> = sorted(rows, Some("e3")).into_iter().map(|row| row.id).collect(); | |
| 970 | assert_eq!(order, ["e3", "e2", "e1"]); | |
| 971 | } | |
| 972 | ||
| 973 | #[test] | |
| 974 | fn the_view_marks_primary_and_backup_and_shows_addresses_as_typed() { | |
| 975 | let rows = vec![row("e1", "a@x.io", true, "1"), row("e2", "b@x.io", true, "2"), row("e3", "c@x.io", false, "3")]; | |
| 976 | let seen = view(&account(Some("e1"), Some("e2"), true), rows); | |
| 977 | assert_eq!(seen.emails[0].email, "A@X.IO"); | |
| 978 | assert!(seen.emails[0].primary && !seen.emails[0].backup); | |
| 979 | assert!(seen.emails[1].backup && !seen.emails[1].primary); | |
| 980 | assert!(!seen.emails[2].verified); | |
| 981 | assert_eq!(seen.noreply, "6c1d0efg+ada@users.noreply.g1t.sh"); | |
| 982 | assert_eq!(seen.commit_email, seen.noreply); | |
| 983 | assert_eq!(seen.limit, 10); | |
| 984 | } | |
| 985 | ||
| 986 | #[test] | |
| 987 | fn commits_use_the_primary_only_when_the_person_allows_it_and_it_is_confirmed() { | |
| 988 | let confirmed = row("e1", "a@x.io", true, "1"); | |
| 989 | let unconfirmed = row("e2", "b@x.io", false, "2"); | |
| 990 | assert_eq!(commit_email(false, Some(&confirmed), "n@noreply"), "a@x.io"); | |
| 991 | assert_eq!(commit_email(true, Some(&confirmed), "n@noreply"), "n@noreply"); | |
| 992 | assert_eq!(commit_email(false, Some(&unconfirmed), "n@noreply"), "n@noreply"); | |
| 993 | assert_eq!(commit_email(false, None, "n@noreply"), "n@noreply"); | |
| 994 | } | |
| 995 | ||
| 996 | #[test] | |
| 997 | fn pushes_are_guarded_only_while_private_and_blocking() { | |
| 998 | let mut ada = account(None, None, true); | |
| 999 | assert!(!guards_pushes(&ada)); | |
| 1000 | ada.block_private_pushes = 1; | |
| 1001 | assert!(guards_pushes(&ada)); | |
| 1002 | ada.private_email = 0; | |
| 1003 | assert!(!guards_pushes(&ada)); | |
| 1004 | } | |
| 1005 | ||
| 1006 | #[test] | |
| 1007 | fn a_link_can_be_sent_again_after_a_minute() { | |
| 1008 | let now = 1_800_000_000_000; | |
| 1009 | assert!(may_resend(None, now)); | |
| 1010 | assert!(!may_resend(Some(&rfc3339(now - 30_000)), now)); | |
| 1011 | assert!(may_resend(Some(&rfc3339(now - 61_000)), now)); | |
| 1012 | } | |
| 1013 | } |