flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/identity/src/emails.rs

1,013 lines45,594 bytesCodeBlame
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
23use std::collections::HashMap;
24
25use g1t_contracts::accounts::*;
26use g1t_contracts::events::UserEmailChanged;
27use g1t_contracts::identity::{UserArgs, UsernameArgs};
28use g1t_contracts::time::{SQL_NOW, rfc3339, sql_after};
29use g1t_contracts::{FailureCode, Outcome, new_id};
30use g1t_kit::now_ms;
31use serde::Deserialize;
32use worker::Result;
33use worker::wasm_bindgen::JsValue;
34
35use crate::security::{PEOPLE_ONLY, is_person};
36use crate::throttle::CONFIRM_ACCOUNT;
37use crate::{Identity, VERIFY_TTL_SECONDS, crypto, email};
38
39/// One row of `user_emails`.
40#[derive(Clone, Debug, Deserialize)]
41pub 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)]
52struct 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
63const 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.
68fn 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
76fn 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.
88fn 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
95fn 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.
123fn 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`.
129pub fn may_resend(sent_at: Option<&str>, now_ms: u64) -> bool {
130 !is_recent(sent_at, now_ms, RESEND_SECONDS)
131}
132
133impl 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)]
928pub 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)]
936mod 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}