oxedyne/fe2o3/fe2o3_steel/src/srv/publish/subscribe.rs
38.2 KiB, 90 runs
created by r1870400018:16205, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! The newsletter's subscribers, in the vhost's own database. |
| 2 | //! |
| 3 | //! "Own the list, own the send." A subscriber is a row in the site's Ozone database, not a record in |
| 4 | //! a third party's, and the mail that reaches them is signed and sent by this host. There is no |
| 5 | //! provider between the site and its readers, and no list that leaves with one. |
| 6 | //! |
| 7 | //! # Double opt-in, because an address is not a consent |
| 8 | //! |
| 9 | //! Anyone can type anyone's address into a form. So a fresh sign-up is [`SubState::Pending`] and |
| 10 | //! receives one thing only -- a confirmation link -- and is promoted to [`SubState::Confirmed`], the |
| 11 | //! state that receives the newsletter, only when that link is followed. An address that never confirms |
| 12 | //! never hears from the site again, which is the difference between a subscriber and a stranger whose |
| 13 | //! address someone knew. |
| 14 | //! |
| 15 | //! # No enumeration, and no scans |
| 16 | //! |
| 17 | //! Subscribing is idempotent and says the same thing whether or not the address was already known: the |
| 18 | //! endpoint answers one "check your inbox" page either way, so the form is not an oracle for whether an |
| 19 | //! address is on the list. And the reads mirror [`super::store`]: the emails live in one index under |
| 20 | //! [`INDEX_KEY`], a subscriber under its own key, and nothing walks the whole database -- a token is |
| 21 | //! matched by reading the index and a record per entry, the same cost a listing already pays. |
| 22 | //! |
| 23 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 24 | //! Anthropic Claude |
| 25 | |
| 26 | use crate::srv::publish::{ |
| 27 | PublishConfig, |
| 28 | send::{ |
| 29 | self, |
| 30 | MailSender, |
| 31 | }, |
| 32 | page, |
| 33 | }; |
| 34 | |
| 35 | use oxedyne_fe2o3_core::{ |
| 36 | prelude::*, |
| 37 | rand::Rand, |
| 38 | }; |
| 39 | use oxedyne_fe2o3_iop_crypto::enc::Encrypter; |
| 40 | use oxedyne_fe2o3_iop_db::api::Database; |
| 41 | use oxedyne_fe2o3_iop_hash::api::Hasher; |
| 42 | use oxedyne_fe2o3_jdat::{ |
| 43 | prelude::*, |
| 44 | id::NumIdDat, |
| 45 | }; |
| 46 | use oxedyne_fe2o3_net::{ |
| 47 | http::msg::HttpMessage, |
| 48 | smtp::client::is_permanent, |
| 49 | }; |
| 50 | |
| 51 | use std::sync::{ |
| 52 | Arc, |
| 53 | RwLock, |
| 54 | }; |
| 55 | |
| 56 | |
| 57 | pub const KEY_PREFIX: &str = "publish/subscriber/"; |
| 58 | |
| 59 | pub const INDEX_KEY: &str = "publish/subscribers"; |
| 60 | |
| 61 | // The longest an address the form will take may be. A generous ceiling: the number is arbitrary, |
| 62 | // having one -- so a form cannot hand the store an unbounded key -- is not. |
| 63 | pub const EMAIL_MAX: usize = 254; |
| 64 | |
| 65 | // How many characters an opt-in token carries. Drawn from TOKEN_ALPHABET, so 32 characters of a |
| 66 | // 36-symbol alphabet is a little over 165 bits: far past guessing. The token is the only thing |
| 67 | // that confirms or unsubscribes an address, so it is the one field here that must be unguessable. |
| 68 | pub const TOKEN_LEN: usize = 32; |
| 69 | |
| 70 | // Deliberately URL-safe and needing no encoding, so the token sits in a `?token=` query and in a |
| 71 | // database key as itself, exactly as a slug's small alphabet does. |
| 72 | const TOKEN_ALPHABET: &str = "abcdefghijklmnopqrstuvwxyz0123456789"; |
| 73 | |
| 74 | |
| 75 | /// Where a subscriber has got to in the double opt-in. |
| 76 | /// |
| 77 | /// The state a piece of mail is gated on: only [`Confirmed`](Self::Confirmed) receives the newsletter. |
| 78 | /// [`Pending`](Self::Pending) has been sent a confirmation and not yet followed it; |
| 79 | /// [`Unsubscribed`](Self::Unsubscribed) has asked to stop and is kept, not deleted, so a later |
| 80 | /// re-subscribe is a fresh opt-in rather than a silent resurrection; [`Bounced`](Self::Bounced) is |
| 81 | /// suppressed -- a permanent delivery failure marked it, and nothing, not even a re-subscribe, sends to |
| 82 | /// it again. |
| 83 | #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] |
| 84 | pub enum SubState { |
| 85 | #[default] |
| 86 | Pending, // signed up and sent a confirmation link; receives nothing but that one link |
| 87 | Confirmed, // followed the link; the one state that receives the newsletter |
| 88 | Unsubscribed, // asked to stop; kept as a record, so re-subscribing opts in afresh |
| 89 | // Suppressed after a permanent delivery failure -- a 5xx, an unknown mailbox. Kept as a record |
| 90 | // and never sent to again: a re-subscribe does not resurrect it, since the address bounced for |
| 91 | // a reason no opt-in changes. |
| 92 | Bounced, |
| 93 | } |
| 94 | |
| 95 | impl SubState { |
| 96 | |
| 97 | /// The word a record stores. |
| 98 | pub fn as_str(&self) -> &'static str { |
| 99 | match self { |
| 100 | Self::Pending => "pending", |
| 101 | Self::Confirmed => "confirmed", |
| 102 | Self::Unsubscribed => "unsubscribed", |
| 103 | Self::Bounced => "bounced", |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | /// The state a word names. **An unknown word is pending**, the safe reading: a state this version |
| 108 | /// cannot place must not thereby be treated as confirmed and sent mail, so it falls to the state |
| 109 | /// that receives none. |
| 110 | pub fn of(s: &str) -> Self { |
| 111 | match s { |
| 112 | "confirmed" => Self::Confirmed, |
| 113 | "unsubscribed" => Self::Unsubscribed, |
| 114 | "bounced" => Self::Bounced, |
| 115 | _ => Self::Pending, |
| 116 | } |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | |
| 121 | /// One subscriber, as the store keeps them. |
| 122 | #[derive(Clone, Debug, Default, Eq, PartialEq)] |
| 123 | pub struct Subscriber { |
| 124 | pub email: String, // normalised: trimmed and lowercased, so one address is one row |
| 125 | pub state: SubState, // where they are in the double opt-in |
| 126 | pub token: String, // unguessable, and minted fresh on each sign-up |
| 127 | pub created: Option<String>, // ISO timestamp, where it is known |
| 128 | } |
| 129 | |
| 130 | impl Subscriber { |
| 131 | |
| 132 | /// The subscriber as a daticle. |
| 133 | /// |
| 134 | /// A plain map, not an ordered one, on the same reasoning as a post record: a subscriber is a set of |
| 135 | /// named fields and nothing depends on their written order. |
| 136 | pub fn to_dat(&self) -> Dat { |
| 137 | let mut m = DaticleMap::new(); |
| 138 | m.insert(dat!("email"), dat!(self.email.clone())); |
| 139 | m.insert(dat!("state"), dat!(self.state.as_str().to_string())); |
| 140 | m.insert(dat!("token"), dat!(self.token.clone())); |
| 141 | // A subscriber with no known sign-up time carries no key for it, on the same footing an undated |
| 142 | // post takes: an absent key and an empty value say the one thing. |
| 143 | if let Some(c) = &self.created { |
| 144 | m.insert(dat!("created"), dat!(c.clone())); |
| 145 | } |
| 146 | Dat::Map(m) |
| 147 | } |
| 148 | |
| 149 | pub fn from_dat(d: &Dat) -> Outcome<Self> { |
| 150 | let m = match d { |
| 151 | Dat::Map(m) => m, |
| 152 | _ => return Err(err!( |
| 153 | "publish: a subscriber record must be a map, not {:?}.", d.kind(); |
| 154 | Invalid, Input, Mismatch)), |
| 155 | }; |
| 156 | let get_str = |key: &str| -> String { |
| 157 | match m.get(&dat!(key)) { |
| 158 | Some(Dat::Str(s)) => s.clone(), |
| 159 | _ => String::new(), |
| 160 | } |
| 161 | }; |
| 162 | let email = get_str("email"); |
| 163 | if email.is_empty() { |
| 164 | return Err(err!( |
| 165 | "publish: a subscriber record names no email."; |
| 166 | Invalid, Input, Missing)); |
| 167 | } |
| 168 | let created = match m.get(&dat!("created")) { |
| 169 | Some(Dat::Str(s)) => Some(s.clone()), |
| 170 | _ => None, |
| 171 | }; |
| 172 | Ok(Self { |
| 173 | email, |
| 174 | state: SubState::of(&get_str("state")), |
| 175 | token: get_str("token"), |
| 176 | created, |
| 177 | }) |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | |
| 182 | fn key_of(email: &str) -> Dat { |
| 183 | let mut s = String::from(KEY_PREFIX); |
| 184 | s.push_str(email); |
| 185 | dat!(s) |
| 186 | } |
| 187 | |
| 188 | /// An address as the store keeps it, from an address as a person typed it: trimmed and lowercased. |
| 189 | /// |
| 190 | /// One shape in the store, so an address typed `Me@Example.COM ` and one typed `me@example.com` are |
| 191 | /// the one subscriber and cannot both be on the list. |
| 192 | pub fn normalise_email(s: &str) -> String { |
| 193 | s.trim().to_lowercase() |
| 194 | } |
| 195 | |
| 196 | /// Whether a normalised address is one the form will take. |
| 197 | /// |
| 198 | /// A shape check, not a delivery guarantee: exactly one `@`, a non-empty local part, a domain that |
| 199 | /// carries a dot and is not a bare label, no whitespace, and within [`EMAIL_MAX`]. The point is to |
| 200 | /// refuse what is plainly not an address before it reaches a key and a piece of mail -- the true test |
| 201 | /// of an address is whether the confirmation to it is ever followed, which is the whole reason for |
| 202 | /// double opt-in. |
| 203 | pub fn valid_email(s: &str) -> bool { |
| 204 | if s.is_empty() || s.len() > EMAIL_MAX { |
| 205 | return false; |
| 206 | } |
| 207 | if s.chars().any(|c| c.is_whitespace()) { |
| 208 | return false; |
| 209 | } |
| 210 | let mut parts = s.split('@'); |
| 211 | let local = match parts.next() { |
| 212 | Some(l) => l, |
| 213 | None => return false, |
| 214 | }; |
| 215 | let domain = match parts.next() { |
| 216 | Some(d) => d, |
| 217 | None => return false, |
| 218 | }; |
| 219 | // A second `@` means more than two parts, so the iterator is not yet exhausted. |
| 220 | if parts.next().is_some() { |
| 221 | return false; |
| 222 | } |
| 223 | if local.is_empty() || domain.is_empty() { |
| 224 | return false; |
| 225 | } |
| 226 | // A domain is at least `a.b`: a dot with something either side, and not at an edge. |
| 227 | if !domain.contains('.') || domain.starts_with('.') || domain.ends_with('.') { |
| 228 | return false; |
| 229 | } |
| 230 | true |
| 231 | } |
| 232 | |
| 233 | /// A fresh, unguessable opt-in token. |
| 234 | pub fn mint_token() -> String { |
| 235 | Rand::generate_random_string(TOKEN_LEN, TOKEN_ALPHABET) |
| 236 | } |
| 237 | |
| 238 | // The name of the field no person fills in. `website`, because that is what a form-filler expects |
| 239 | // to find on a form, and filling it is the tell. The form must place it out of view without |
| 240 | // `display: none` or `hidden`, which the better form-fillers skip. |
| 241 | pub const TRAP_FIELD: &str = "website"; |
| 242 | |
| 243 | const RATE_PREFIX: &str = "publish/subscribe-rate/"; // apart from the comment counter |
| 244 | |
| 245 | /// Whether a submission filled in the field no person sees. |
| 246 | /// |
| 247 | /// Whitespace is not a fill: a browser that helpfully trims or a proxy that pads should not cost a |
| 248 | /// reader their sign-up. |
| 249 | pub fn trapped(value: &str) -> bool { |
| 250 | !value.trim().is_empty() |
| 251 | } |
| 252 | |
| 253 | /// A salted, one-way rendering of where a sign-up came from. |
| 254 | /// |
| 255 | /// The same trade [`super::comment::from_hash`] makes, with its own domain separator so one |
| 256 | /// counter's values are not the other's: enough to recognise a repeat, not enough to reconstruct an |
| 257 | /// address. The store holds no readable record of who signed up from where. |
| 258 | fn from_hash(addr: &str, salt: &[u8]) -> String { |
| 259 | super::comment::hash_with(addr, b"subscribe-from", salt) |
| 260 | } |
| 261 | |
| 262 | |
| 263 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 264 | // │ STORE │ |
| 265 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 266 | |
| 267 | pub fn get< |
| 268 | const UIDL: usize, |
| 269 | UID: NumIdDat<UIDL>, |
| 270 | ENC: Encrypter, |
| 271 | KH: Hasher, |
| 272 | DB: Database<UIDL, UID, ENC, KH>, |
| 273 | >( |
| 274 | db: &(Arc<RwLock<DB>>, UID), |
| 275 | email: &str, |
| 276 | ) |
| 277 | -> Outcome<Option<Subscriber>> |
| 278 | { |
| 279 | let (db_arc, _) = db; |
| 280 | let guard = lock_read!(db_arc); |
| 281 | match res!(guard.get(&key_of(email), None)) { |
| 282 | Some((val, _)) => Ok(Some(res!(Subscriber::from_dat(&val)))), |
| 283 | None => Ok(None), |
| 284 | } |
| 285 | } |
| 286 | |
| 287 | /// Writes a subscriber, adding it to the index if it is new. |
| 288 | fn put< |
| 289 | const UIDL: usize, |
| 290 | UID: NumIdDat<UIDL>, |
| 291 | ENC: Encrypter, |
| 292 | KH: Hasher, |
| 293 | DB: Database<UIDL, UID, ENC, KH>, |
| 294 | >( |
| 295 | db: &(Arc<RwLock<DB>>, UID), |
| 296 | sub: &Subscriber, |
| 297 | ) |
| 298 | -> Outcome<()> |
| 299 | { |
| 300 | let (db_arc, user) = db; |
| 301 | { |
| 302 | let guard = lock_read!(db_arc); |
| 303 | res!(guard.insert(key_of(&sub.email), sub.to_dat(), *user, None)); |
| 304 | } |
| 305 | let mut emails = res!(index(db)); |
| 306 | if !emails.iter().any(|e| e == &sub.email) { |
| 307 | emails.push(sub.email.clone()); |
| 308 | res!(put_index(db, &emails)); |
| 309 | } |
| 310 | Ok(()) |
| 311 | } |
| 312 | |
| 313 | fn index< |
| 314 | const UIDL: usize, |
| 315 | UID: NumIdDat<UIDL>, |
| 316 | ENC: Encrypter, |
| 317 | KH: Hasher, |
| 318 | DB: Database<UIDL, UID, ENC, KH>, |
| 319 | >( |
| 320 | db: &(Arc<RwLock<DB>>, UID), |
| 321 | ) |
| 322 | -> Outcome<Vec<String>> |
| 323 | { |
| 324 | let (db_arc, _) = db; |
| 325 | let guard = lock_read!(db_arc); |
| 326 | let val = match res!(guard.get(&dat!(INDEX_KEY), None)) { |
| 327 | Some((v, _)) => v, |
| 328 | // No index is a list nobody has subscribed to, not an error -- the empty list it never wrote. |
| 329 | None => return Ok(Vec::new()), |
| 330 | }; |
| 331 | let items = match &val { |
| 332 | Dat::List(items) => items.clone(), |
| 333 | Dat::Vek(vek) => vek.as_slice().to_vec(), |
| 334 | _ => return Err(err!( |
| 335 | "publish: the subscriber index must be a list, not {:?}.", val.kind(); |
| 336 | Invalid, Input, Mismatch)), |
| 337 | }; |
| 338 | let mut out = Vec::new(); |
| 339 | for item in &items { |
| 340 | if let Dat::Str(s) = item { |
| 341 | out.push(s.clone()); |
| 342 | } |
| 343 | } |
| 344 | Ok(out) |
| 345 | } |
| 346 | |
| 347 | fn put_index< |
| 348 | const UIDL: usize, |
| 349 | UID: NumIdDat<UIDL>, |
| 350 | ENC: Encrypter, |
| 351 | KH: Hasher, |
| 352 | DB: Database<UIDL, UID, ENC, KH>, |
| 353 | >( |
| 354 | db: &(Arc<RwLock<DB>>, UID), |
| 355 | emails: &[String], |
| 356 | ) |
| 357 | -> Outcome<()> |
| 358 | { |
| 359 | let (db_arc, user) = db; |
| 360 | let list = Dat::List(emails.iter().map(|e| dat!(e.clone())).collect()); |
| 361 | let guard = lock_read!(db_arc); |
| 362 | res!(guard.insert(dat!(INDEX_KEY), list, *user, None)); |
| 363 | Ok(()) |
| 364 | } |
| 365 | |
| 366 | /// Every subscriber the store holds, in index order. |
| 367 | /// |
| 368 | /// A record the index names but the database does not hold is passed over with a complaint, rather |
| 369 | /// than failing the lot, on the same reasoning [`super::store::list_records`] takes. |
| 370 | pub fn list< |
| 371 | const UIDL: usize, |
| 372 | UID: NumIdDat<UIDL>, |
| 373 | ENC: Encrypter, |
| 374 | KH: Hasher, |
| 375 | DB: Database<UIDL, UID, ENC, KH>, |
| 376 | >( |
| 377 | db: &(Arc<RwLock<DB>>, UID), |
| 378 | id: &str, |
| 379 | ) |
| 380 | -> Outcome<Vec<Subscriber>> |
| 381 | { |
| 382 | let emails = res!(index(db)); |
| 383 | let mut out = Vec::new(); |
| 384 | for email in &emails { |
| 385 | match get(db, email) { |
| 386 | Ok(Some(s)) => out.push(s), |
| 387 | Ok(None) => warn!( |
| 388 | "{}: publish: the subscriber index names {}, which is not there", id, redact(email)), |
| 389 | Err(e) => warn!("{}: publish: skipping subscriber {}: {}", id, redact(email), e), |
| 390 | } |
| 391 | } |
| 392 | Ok(out) |
| 393 | } |
| 394 | |
| 395 | /// How many subscribers the store holds, whatever their state. |
| 396 | pub fn count< |
| 397 | const UIDL: usize, |
| 398 | UID: NumIdDat<UIDL>, |
| 399 | ENC: Encrypter, |
| 400 | KH: Hasher, |
| 401 | DB: Database<UIDL, UID, ENC, KH>, |
| 402 | >( |
| 403 | db: &(Arc<RwLock<DB>>, UID), |
| 404 | id: &str, |
| 405 | ) |
| 406 | -> Outcome<usize> |
| 407 | { |
| 408 | Ok(res!(list(db, id)).len()) |
| 409 | } |
| 410 | |
| 411 | /// The send set: every confirmed subscriber, the only ones a newsletter reaches. |
| 412 | /// |
| 413 | /// Whole subscribers rather than bare addresses, because each carries the token the newsletter's own |
| 414 | /// unsubscribe link is built from -- one link per recipient, so the person who clicks it removes |
| 415 | /// themselves and nobody else. |
| 416 | pub fn confirmed< |
| 417 | const UIDL: usize, |
| 418 | UID: NumIdDat<UIDL>, |
| 419 | ENC: Encrypter, |
| 420 | KH: Hasher, |
| 421 | DB: Database<UIDL, UID, ENC, KH>, |
| 422 | >( |
| 423 | db: &(Arc<RwLock<DB>>, UID), |
| 424 | id: &str, |
| 425 | ) |
| 426 | -> Outcome<Vec<Subscriber>> |
| 427 | { |
| 428 | Ok(res!(list(db, id)).into_iter().filter(|s| s.state == SubState::Confirmed).collect()) |
| 429 | } |
| 430 | |
| 431 | /// The subscriber list as CSV: address, state, sign-up time. |
| 432 | /// |
| 433 | /// The list the site owns, in the form anything reads -- a spreadsheet, another tool, a backup. The |
| 434 | /// header names the columns; a field carrying a comma or a quote is quoted, so an address never splits |
| 435 | /// a row. |
| 436 | pub fn export< |
| 437 | const UIDL: usize, |
| 438 | UID: NumIdDat<UIDL>, |
| 439 | ENC: Encrypter, |
| 440 | KH: Hasher, |
| 441 | DB: Database<UIDL, UID, ENC, KH>, |
| 442 | >( |
| 443 | db: &(Arc<RwLock<DB>>, UID), |
| 444 | id: &str, |
| 445 | ) |
| 446 | -> Outcome<String> |
| 447 | { |
| 448 | let subs = res!(list(db, id)); |
| 449 | let mut out = String::from("email,state,created\n"); |
| 450 | for s in &subs { |
| 451 | out.push_str(&csv_field(&s.email)); |
| 452 | out.push(','); |
| 453 | out.push_str(s.state.as_str()); |
| 454 | out.push(','); |
| 455 | out.push_str(&csv_field(s.created.as_deref().unwrap_or(""))); |
| 456 | out.push('\n'); |
| 457 | } |
| 458 | Ok(out) |
| 459 | } |
| 460 | |
| 461 | /// A CSV field, quoted where it carries a comma, a quote or a newline. |
| 462 | fn csv_field(s: &str) -> String { |
| 463 | if s.contains(',') || s.contains('"') || s.contains('\n') { |
| 464 | fmt!("\"{}\"", s.replace('"', "\"\"")) |
| 465 | } else { |
| 466 | s.to_string() |
| 467 | } |
| 468 | } |
| 469 | |
| 470 | /// The subscriber a token names, by reading the index and a record per entry. |
| 471 | /// |
| 472 | /// Index-driven, like every read here: no scan. The list is a newsletter's, not a social network's, so |
| 473 | /// a read per entry to match a token is a cost worth its simplicity. |
| 474 | fn find_by_token< |
| 475 | const UIDL: usize, |
| 476 | UID: NumIdDat<UIDL>, |
| 477 | ENC: Encrypter, |
| 478 | KH: Hasher, |
| 479 | DB: Database<UIDL, UID, ENC, KH>, |
| 480 | >( |
| 481 | db: &(Arc<RwLock<DB>>, UID), |
| 482 | token: &str, |
| 483 | id: &str, |
| 484 | ) |
| 485 | -> Outcome<Option<Subscriber>> |
| 486 | { |
| 487 | if token.is_empty() { |
| 488 | return Ok(None); |
| 489 | } |
| 490 | for sub in res!(list(db, id)) { |
| 491 | if sub.token == token { |
| 492 | return Ok(Some(sub)); |
| 493 | } |
| 494 | } |
| 495 | Ok(None) |
| 496 | } |
| 497 | |
| 498 | /// Records a pending sign-up and says whether a confirmation should be sent. |
| 499 | /// |
| 500 | /// Idempotent, and deliberately not an oracle: |
| 501 | /// |
| 502 | /// - A **new** or previously **unsubscribed** address is written [`Pending`](SubState::Pending) with a |
| 503 | /// fresh token, and `Some(subscriber)` is returned: send them a confirmation. |
| 504 | /// - An address already **pending** is re-issued a fresh token and re-sent -- the earlier link may be |
| 505 | /// lost -- and `Some(subscriber)` is returned. |
| 506 | /// - An address already **confirmed** is left exactly as it is and `None` is returned: it is on the |
| 507 | /// list, and re-confirming it would be a second welcome to someone who never left. |
| 508 | /// - An address **bounced** is left suppressed and `None` is returned: a permanent failure marked it, |
| 509 | /// and a re-subscribe must not resurrect an address the mail server said does not exist. |
| 510 | /// |
| 511 | /// The caller answers the same page whichever it gets, so the form never reveals which case it was. |
| 512 | pub fn add_pending< |
| 513 | const UIDL: usize, |
| 514 | UID: NumIdDat<UIDL>, |
| 515 | ENC: Encrypter, |
| 516 | KH: Hasher, |
| 517 | DB: Database<UIDL, UID, ENC, KH>, |
| 518 | >( |
| 519 | db: &(Arc<RwLock<DB>>, UID), |
| 520 | email: &str, |
| 521 | ) |
| 522 | -> Outcome<Option<Subscriber>> |
| 523 | { |
| 524 | let email = normalise_email(email); |
| 525 | if !valid_email(&email) { |
| 526 | return Err(err!( |
| 527 | "publish: {} is not a shape an address takes.", redact(&email); |
| 528 | Invalid, Input)); |
| 529 | } |
| 530 | // An address already confirmed is on the list; do not welcome it twice. A bounced address is |
| 531 | // suppressed and stays so -- a re-subscribe does not undo a permanent failure. Neither leaks that it |
| 532 | // is known, since the caller shows the same page whether `Some` or `None` comes back. |
| 533 | if let Some(existing) = res!(get(db, &email)) { |
| 534 | match existing.state { |
| 535 | SubState::Confirmed | SubState::Bounced => return Ok(None), |
| 536 | _ => {} |
| 537 | } |
| 538 | } |
| 539 | let sub = Subscriber { |
| 540 | email: email.clone(), |
| 541 | state: SubState::Pending, |
| 542 | token: mint_token(), |
| 543 | created: send::iso_now().ok(), |
| 544 | }; |
| 545 | res!(put(db, &sub)); |
| 546 | Ok(Some(sub)) |
| 547 | } |
| 548 | |
| 549 | /// What a confirmation link found when it was followed. |
| 550 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] |
| 551 | pub enum ConfirmOutcome { |
| 552 | Confirmed, // promoted from pending: the newsletter now reaches them |
| 553 | // The token named a subscriber already confirmed. The safe, idempotent answer to a link |
| 554 | // followed twice: they are on the list, said so, and nothing changed. |
| 555 | Already, |
| 556 | // The token named nobody: it is malformed, expired by a re-subscribe that minted a new one, or |
| 557 | // never existed. |
| 558 | Unknown, |
| 559 | } |
| 560 | |
| 561 | pub fn confirm< |
| 562 | const UIDL: usize, |
| 563 | UID: NumIdDat<UIDL>, |
| 564 | ENC: Encrypter, |
| 565 | KH: Hasher, |
| 566 | DB: Database<UIDL, UID, ENC, KH>, |
| 567 | >( |
| 568 | db: &(Arc<RwLock<DB>>, UID), |
| 569 | token: &str, |
| 570 | id: &str, |
| 571 | ) |
| 572 | -> Outcome<ConfirmOutcome> |
| 573 | { |
| 574 | let mut sub = match res!(find_by_token(db, token, id)) { |
| 575 | Some(s) => s, |
| 576 | None => return Ok(ConfirmOutcome::Unknown), |
| 577 | }; |
| 578 | match sub.state { |
| 579 | SubState::Confirmed => Ok(ConfirmOutcome::Already), |
| 580 | _ => { |
| 581 | sub.state = SubState::Confirmed; |
| 582 | res!(put(db, &sub)); |
| 583 | info!("{}: publish: {} confirmed their subscription", id, redact(&sub.email)); |
| 584 | Ok(ConfirmOutcome::Confirmed) |
| 585 | } |
| 586 | } |
| 587 | } |
| 588 | |
| 589 | /// What an unsubscribe link found when it was followed. |
| 590 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] |
| 591 | pub enum UnsubOutcome { |
| 592 | Done, // set unsubscribed, or already was, so no more mail reaches them either way |
| 593 | Unknown, // the token named nobody |
| 594 | } |
| 595 | |
| 596 | /// Sets a subscriber unsubscribed, by their token. |
| 597 | /// |
| 598 | /// The record is kept, not deleted: a later re-subscribe is a fresh opt-in through |
| 599 | /// [`add_pending`], not a silent return to a list they asked to leave. |
| 600 | pub fn unsubscribe< |
| 601 | const UIDL: usize, |
| 602 | UID: NumIdDat<UIDL>, |
| 603 | ENC: Encrypter, |
| 604 | KH: Hasher, |
| 605 | DB: Database<UIDL, UID, ENC, KH>, |
| 606 | >( |
| 607 | db: &(Arc<RwLock<DB>>, UID), |
| 608 | token: &str, |
| 609 | id: &str, |
| 610 | ) |
| 611 | -> Outcome<UnsubOutcome> |
| 612 | { |
| 613 | let mut sub = match res!(find_by_token(db, token, id)) { |
| 614 | Some(s) => s, |
| 615 | None => return Ok(UnsubOutcome::Unknown), |
| 616 | }; |
| 617 | if sub.state != SubState::Unsubscribed { |
| 618 | sub.state = SubState::Unsubscribed; |
| 619 | res!(put(db, &sub)); |
| 620 | info!("{}: publish: {} unsubscribed", id, redact(&sub.email)); |
| 621 | } |
| 622 | Ok(UnsubOutcome::Done) |
| 623 | } |
| 624 | |
| 625 | /// Sets a subscriber unsubscribed, by their address, for the admin console. |
| 626 | /// |
| 627 | /// The address-keyed twin of [`unsubscribe`], which the public link uses by token. The admin acts on the |
| 628 | /// address they see in the list, not a token, so this reads the record by its key. The record is kept, |
| 629 | /// not deleted -- an admin who means to erase calls [`remove`]. `false` where the store holds no such |
| 630 | /// address, so the caller can say the subscriber was not there rather than claim an unsubscribe that |
| 631 | /// changed nothing. |
| 632 | pub fn unsubscribe_email< |
| 633 | const UIDL: usize, |
| 634 | UID: NumIdDat<UIDL>, |
| 635 | ENC: Encrypter, |
| 636 | KH: Hasher, |
| 637 | DB: Database<UIDL, UID, ENC, KH>, |
| 638 | >( |
| 639 | db: &(Arc<RwLock<DB>>, UID), |
| 640 | email: &str, |
| 641 | id: &str, |
| 642 | ) |
| 643 | -> Outcome<bool> |
| 644 | { |
| 645 | let email = normalise_email(email); |
| 646 | let mut sub = match res!(get(db, &email)) { |
| 647 | Some(s) => s, |
| 648 | None => return Ok(false), |
| 649 | }; |
| 650 | if sub.state != SubState::Unsubscribed { |
| 651 | sub.state = SubState::Unsubscribed; |
| 652 | res!(put(db, &sub)); |
| 653 | info!("{}: publish: {} unsubscribed by an admin", id, redact(&sub.email)); |
| 654 | } |
| 655 | Ok(true) |
| 656 | } |
| 657 | |
| 658 | /// Suppresses a subscriber after a permanent delivery failure, by their address. |
| 659 | /// |
| 660 | /// The send set is built from [`SubState::Confirmed`] alone, so a bounced address leaves it at once and |
| 661 | /// is never mailed again -- not by the newsletter, and not by a re-subscribe, since [`add_pending`] |
| 662 | /// keeps a bounced record suppressed. The record is kept so the suppression is durable and countable; |
| 663 | /// only a permanent failure calls this, never a transient one. `false` where the store holds no such |
| 664 | /// address, and a no-op where it is already bounced. |
| 665 | pub fn mark_bounced< |
| 666 | const UIDL: usize, |
| 667 | UID: NumIdDat<UIDL>, |
| 668 | ENC: Encrypter, |
| 669 | KH: Hasher, |
| 670 | DB: Database<UIDL, UID, ENC, KH>, |
| 671 | >( |
| 672 | db: &(Arc<RwLock<DB>>, UID), |
| 673 | email: &str, |
| 674 | id: &str, |
| 675 | ) |
| 676 | -> Outcome<bool> |
| 677 | { |
| 678 | let email = normalise_email(email); |
| 679 | let mut sub = match res!(get(db, &email)) { |
| 680 | Some(s) => s, |
| 681 | None => return Ok(false), |
| 682 | }; |
| 683 | if sub.state != SubState::Bounced { |
| 684 | sub.state = SubState::Bounced; |
| 685 | res!(put(db, &sub)); |
| 686 | warn!("{}: publish: {} suppressed after a permanent delivery failure", id, redact(&sub.email)); |
| 687 | } |
| 688 | Ok(true) |
| 689 | } |
| 690 | |
| 691 | /// Erases a subscriber outright: the record and its place in the index both, by their address. |
| 692 | /// |
| 693 | /// A GDPR erasure, distinct from [`unsubscribe_email`]: an unsubscribe keeps the record so a re-subscribe |
| 694 | /// opts in afresh, whereas this leaves nothing behind -- no state, no token, no row in the count. Mirrors |
| 695 | /// [`super::store::delete`]: the key is deleted and the address filtered out of the index, so a listing |
| 696 | /// does not name what is gone. `true` where an address was there to erase. |
| 697 | pub fn remove< |
| 698 | const UIDL: usize, |
| 699 | UID: NumIdDat<UIDL>, |
| 700 | ENC: Encrypter, |
| 701 | KH: Hasher, |
| 702 | DB: Database<UIDL, UID, ENC, KH>, |
| 703 | >( |
| 704 | db: &(Arc<RwLock<DB>>, UID), |
| 705 | email: &str, |
| 706 | id: &str, |
| 707 | ) |
| 708 | -> Outcome<bool> |
| 709 | { |
| 710 | let email = normalise_email(email); |
| 711 | // Whether the address was really there, read by key so a tombstone reads as absent -- unlike the |
| 712 | // database's own `delete`, which marks a key for deletion and reports success even for one already |
| 713 | // gone. So a repeat erase honestly says there was nothing to erase. |
| 714 | let existed = res!(get(db, &email)).is_some(); |
| 715 | let (db_arc, user) = db; |
| 716 | { |
| 717 | let guard = lock_read!(db_arc); |
| 718 | res!(guard.delete(&key_of(&email), *user, None)); |
| 719 | } |
| 720 | let emails = res!(index(db)); |
| 721 | let kept: Vec<String> = emails.into_iter().filter(|e| e != &email).collect(); |
| 722 | res!(put_index(db, &kept)); |
| 723 | if existed { |
| 724 | info!("{}: publish: {} erased from the list by an admin", id, redact(&email)); |
| 725 | } |
| 726 | Ok(existed) |
| 727 | } |
| 728 | |
| 729 | /// An address with its local part masked, for a log line. |
| 730 | /// |
| 731 | /// The domain is kept -- it is useful and not private -- and the local part is reduced to its first |
| 732 | /// character, so a log is a record of what happened without being a copy of the list. |
| 733 | pub fn redact(email: &str) -> String { |
| 734 | match email.split_once('@') { |
| 735 | Some((local, domain)) => { |
| 736 | let first = local.chars().next().unwrap_or('?'); |
| 737 | fmt!("{}***@{}", first, domain) |
| 738 | } |
| 739 | None => fmt!("***"), |
| 740 | } |
| 741 | } |
| 742 | |
| 743 | |
| 744 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 745 | // │ THE PUBLIC ENDPOINTS │ |
| 746 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 747 | |
| 748 | /// The themed sign-up form, for a `GET {path}/subscribe`. |
| 749 | /// |
| 750 | /// A working, script-free form the site can link to directly, and the shape the site's own inline form |
| 751 | /// should mirror: a `POST` to the same path with one field, `email`. |
| 752 | pub fn subscribe_form(cfg: &PublishConfig) -> HttpMessage { |
| 753 | page::subscribe_form_page(cfg) |
| 754 | } |
| 755 | |
| 756 | /// Records a pending sign-up and sends the confirmation, for a `POST {path}/subscribe`. |
| 757 | /// |
| 758 | /// Always answers the same "check your inbox" page, whether the address was new, pending or already |
| 759 | /// confirmed, so nothing here reveals whether an address is on the list. Where mail is not configured, |
| 760 | /// or the site has no canonical origin to build an absolute confirmation link from, it says the |
| 761 | /// newsletter is not set up rather than storing a pending subscriber it can never confirm. |
| 762 | /// |
| 763 | /// # What stands between a stranger and this host's outbound mail |
| 764 | /// |
| 765 | /// This endpoint is unauthenticated and its effect is a piece of mail to an address the sender |
| 766 | /// chose. Left bare it is a mail-bombing tool wearing the site's own domain, and the cost is not |
| 767 | /// the disk -- it is the sending reputation every later confirmation depends on. So, before |
| 768 | /// anything is stored or sent: |
| 769 | /// |
| 770 | /// - **The field no person fills in.** [`TRAP_FIELD`] filled means a machine filled it. The |
| 771 | /// submission is dropped and answered with the ordinary page, since telling a bot it was spotted |
| 772 | /// only teaches it which field to leave alone. |
| 773 | /// - **A limit per sender**, keyed on a salted hash of where the request came from and counted |
| 774 | /// apart from the comment limiter. Over it, the same page again: a form that says "you are doing |
| 775 | /// that too often" is a form that tells a script exactly what it has found. |
| 776 | /// |
| 777 | /// Double opt-in is the third layer and the one already here: an address that never confirms hears |
| 778 | /// nothing further, so the worst a flood achieves is one message per address rather than a |
| 779 | /// correspondence. |
| 780 | pub async fn handle_subscribe< |
| 781 | const UIDL: usize, |
| 782 | UID: NumIdDat<UIDL>, |
| 783 | ENC: Encrypter, |
| 784 | KH: Hasher, |
| 785 | DB: Database<UIDL, UID, ENC, KH>, |
| 786 | >( |
| 787 | cfg: &PublishConfig, |
| 788 | db: Option<&(Arc<RwLock<DB>>, UID)>, |
| 789 | mail: &Option<Arc<MailSender>>, |
| 790 | body: &[u8], |
| 791 | from: Option<&str>, |
| 792 | id: &str, |
| 793 | ) |
| 794 | -> Outcome<HttpMessage> |
| 795 | { |
| 796 | let db = match db { |
| 797 | Some(db) => db, |
| 798 | None => return Ok(page::subscribe_unavailable_page(cfg)), |
| 799 | }; |
| 800 | // The newsletter needs a sender to post the confirmation, and an absolute origin to build the link |
| 801 | // it carries. Missing either, the honest answer is that signup is not available -- not a pending row |
| 802 | // that will wait for a confirmation nothing can send. |
| 803 | let sender = match mail { |
| 804 | Some(m) => m, |
| 805 | None => return Ok(page::subscribe_unavailable_page(cfg)), |
| 806 | }; |
| 807 | if cfg.base_url.is_empty() { |
| 808 | warn!("{}: publish: a subscribe arrived but the site has no base_url for a confirm link", id); |
| 809 | return Ok(page::subscribe_unavailable_page(cfg)); |
| 810 | } |
| 811 | |
| 812 | // The trap, read before the address: a filled one means nothing else about this submission is |
| 813 | // worth the reads it would cost. |
| 814 | let trap = crate::srv::console::form_field(body, TRAP_FIELD).unwrap_or_default(); |
| 815 | if trapped(&trap) { |
| 816 | info!("{}: publish: a sign-up filled the field no person sees; dropped", id); |
| 817 | return Ok(page::subscribe_sent_page(cfg)); |
| 818 | } |
| 819 | |
| 820 | // What this sender is allowed. A refusal costs one read and writes no subscriber, which is why |
| 821 | // it comes before the store and the mail. A request with no address behind it -- which should |
| 822 | // not happen, since the caller supplies one -- is not limited here. |
| 823 | if let Some(addr) = from { |
| 824 | let salt = res!(crate::srv::publish::comment::site_secret(db)); |
| 825 | let hashed = from_hash(addr, &salt); |
| 826 | if !res!(crate::srv::publish::comment::rate_allows_at( |
| 827 | db, RATE_PREFIX, &hashed, cfg.subscribe_rate_secs, cfg.subscribe_rate_hourly)) |
| 828 | { |
| 829 | info!("{}: publish: a sender is signing up faster than this site allows", id); |
| 830 | return Ok(page::subscribe_sent_page(cfg)); |
| 831 | } |
| 832 | } |
| 833 | |
| 834 | let email = crate::srv::console::form_field(body, "email").unwrap_or_default(); |
| 835 | let email = normalise_email(&email); |
| 836 | // A plainly malformed address is told so on its own page: that reveals nothing about the list, only |
| 837 | // about what was typed. |
| 838 | if !valid_email(&email) { |
| 839 | return Ok(page::subscribe_invalid_page(cfg)); |
| 840 | } |
| 841 | |
| 842 | match res!(add_pending(db, &email)) { |
| 843 | // New or pending: send the confirmation. A send that fails is logged, and the reader still gets |
| 844 | // the same page -- retrying the form re-sends, and saying "we could not email you" would leak |
| 845 | // that the address was actionable. |
| 846 | Some(sub) => { |
| 847 | let url = cfg.url_of(&cfg.confirm_path(&sub.token)); |
| 848 | let from = cfg.newsletter_from(sender); |
| 849 | match sender.send_confirmation(&from, &sub.email, &url, &cfg.site_name).await { |
| 850 | Ok(_) => info!("{}: publish: confirmation sent to {}", id, redact(&sub.email)), |
| 851 | // A permanent failure means the address does not exist; suppress it so a retry of the form |
| 852 | // does not keep mailing a mailbox the server has refused. A transient failure is left to be |
| 853 | // retried by the form, exactly as before. |
| 854 | Err(e) if is_permanent(&e) => { |
| 855 | warn!("{}: publish: confirmation to {} failed permanently; suppressing: {}", |
| 856 | id, redact(&sub.email), e); |
| 857 | if let Err(e2) = mark_bounced(db, &sub.email, id) { |
| 858 | warn!("{}: publish: could not suppress {}: {}", id, redact(&sub.email), e2); |
| 859 | } |
| 860 | } |
| 861 | Err(e) => warn!("{}: publish: confirmation to {} did not send: {}", |
| 862 | id, redact(&sub.email), e), |
| 863 | } |
| 864 | } |
| 865 | // Already confirmed: send nothing, and answer identically. |
| 866 | None => debug!("{}: publish: subscribe for an address already on the list", id), |
| 867 | } |
| 868 | |
| 869 | Ok(page::subscribe_sent_page(cfg)) |
| 870 | } |
| 871 | |
| 872 | /// Confirms a pending subscriber, for a `GET {path}/confirm?token=...`. |
| 873 | pub fn handle_confirm< |
| 874 | const UIDL: usize, |
| 875 | UID: NumIdDat<UIDL>, |
| 876 | ENC: Encrypter, |
| 877 | KH: Hasher, |
| 878 | DB: Database<UIDL, UID, ENC, KH>, |
| 879 | >( |
| 880 | cfg: &PublishConfig, |
| 881 | db: Option<&(Arc<RwLock<DB>>, UID)>, |
| 882 | query: &str, |
| 883 | id: &str, |
| 884 | ) |
| 885 | -> Outcome<HttpMessage> |
| 886 | { |
| 887 | let db = match db { |
| 888 | Some(db) => db, |
| 889 | None => return Ok(page::subscribe_unavailable_page(cfg)), |
| 890 | }; |
| 891 | let token = token_of(query); |
| 892 | match res!(confirm(db, &token, id)) { |
| 893 | ConfirmOutcome::Confirmed => Ok(page::subscribe_confirmed_page(cfg)), |
| 894 | ConfirmOutcome::Already => Ok(page::subscribe_confirmed_page(cfg)), |
| 895 | ConfirmOutcome::Unknown => Ok(page::subscribe_bad_token_page(cfg)), |
| 896 | } |
| 897 | } |
| 898 | |
| 899 | /// Unsubscribes a subscriber, for a `GET {path}/unsubscribe?token=...`. |
| 900 | /// |
| 901 | /// A `GET` for a click from an email, which is where an unsubscribe link is followed. It removes and |
| 902 | /// says so idempotently -- a token followed twice lands on the same page. |
| 903 | pub fn handle_unsubscribe< |
| 904 | const UIDL: usize, |
| 905 | UID: NumIdDat<UIDL>, |
| 906 | ENC: Encrypter, |
| 907 | KH: Hasher, |
| 908 | DB: Database<UIDL, UID, ENC, KH>, |
| 909 | >( |
| 910 | cfg: &PublishConfig, |
| 911 | db: Option<&(Arc<RwLock<DB>>, UID)>, |
| 912 | query: &str, |
| 913 | id: &str, |
| 914 | ) |
| 915 | -> Outcome<HttpMessage> |
| 916 | { |
| 917 | let db = match db { |
| 918 | Some(db) => db, |
| 919 | None => return Ok(page::subscribe_unavailable_page(cfg)), |
| 920 | }; |
| 921 | let token = token_of(query); |
| 922 | match res!(unsubscribe(db, &token, id)) { |
| 923 | UnsubOutcome::Done => Ok(page::subscribe_unsubscribed_page(cfg)), |
| 924 | UnsubOutcome::Unknown => Ok(page::subscribe_bad_token_page(cfg)), |
| 925 | } |
| 926 | } |
| 927 | |
| 928 | /// The `token=` value out of a raw query substring. |
| 929 | /// |
| 930 | /// A token is [`TOKEN_ALPHABET`] -- lowercase letters and digits -- so a value carrying anything a |
| 931 | /// query would percent-encode is a value no token wears, and matches nobody. Read with no decoding, so |
| 932 | /// a `%2e` reaching here stays `%2e` and finds nothing, which is the right answer to a token that does |
| 933 | /// not exist. |
| 934 | fn token_of(query: &str) -> String { |
| 935 | for pair in query.split('&') { |
| 936 | let mut kv = pair.splitn(2, '='); |
| 937 | let k = kv.next().unwrap_or(""); |
| 938 | let v = kv.next().unwrap_or(""); |
| 939 | if k == "token" { |
| 940 | return v.to_string(); |
| 941 | } |
| 942 | } |
| 943 | String::new() |
| 944 | } |
| 945 | |
| 946 | |
| 947 | #[cfg(test)] |
| 948 | mod tests { |
| 949 | use super::*; |
| 950 | |
| 951 | /// An address is trimmed and lowercased to one shape, and shape-checked against the obvious wrongs. |
| 952 | #[test] |
| 953 | fn test_an_address_is_normalised_and_checked_00() -> Outcome<()> { |
| 954 | assert_eq!(normalise_email(" Me@Example.COM "), "me@example.com"); |
| 955 | assert!(valid_email("me@example.com")); |
| 956 | assert!(valid_email("a.b+tag@sub.example.co.uk")); |
| 957 | assert!(!valid_email("")); |
| 958 | assert!(!valid_email("no-at-sign")); |
| 959 | assert!(!valid_email("two@@example.com")); |
| 960 | assert!(!valid_email("@example.com")); |
| 961 | assert!(!valid_email("me@")); |
| 962 | assert!(!valid_email("me@localhost")); // no dot in the domain |
| 963 | assert!(!valid_email("me@.com")); |
| 964 | assert!(!valid_email("me@example.")); |
| 965 | assert!(!valid_email("has space@example.com")); |
| 966 | assert!(!valid_email(&fmt!("{}@example.com", "x".repeat(EMAIL_MAX)))); |
| 967 | Ok(()) |
| 968 | } |
| 969 | |
| 970 | /// A subscriber survives the trip through a daticle, with and without a sign-up time. |
| 971 | #[test] |
| 972 | fn test_a_subscriber_round_trips_01() -> Outcome<()> { |
| 973 | let sub = Subscriber { |
| 974 | email: fmt!("me@example.com"), |
| 975 | state: SubState::Confirmed, |
| 976 | token: fmt!("abc123"), |
| 977 | created: Some(fmt!("2026-07-18T10:00:00Z")), |
| 978 | }; |
| 979 | let back = res!(Subscriber::from_dat(&sub.to_dat())); |
| 980 | assert_eq!(back, sub); |
| 981 | |
| 982 | let undated = Subscriber { created: None, ..sub }; |
| 983 | let back = res!(Subscriber::from_dat(&undated.to_dat())); |
| 984 | assert_eq!(back, undated); |
| 985 | assert_eq!(back.created, None); |
| 986 | Ok(()) |
| 987 | } |
| 988 | |
| 989 | /// A state this version cannot read is pending -- the state that receives no mail -- not confirmed. |
| 990 | #[test] |
| 991 | fn test_an_unreadable_state_is_pending_02() -> Outcome<()> { |
| 992 | assert_eq!(SubState::of("confirmed"), SubState::Confirmed); |
| 993 | assert_eq!(SubState::of("unsubscribed"), SubState::Unsubscribed); |
| 994 | assert_eq!(SubState::of("bounced"), SubState::Bounced); |
| 995 | assert_eq!(SubState::of("something-new"), SubState::Pending); |
| 996 | Ok(()) |
| 997 | } |
| 998 | |
| 999 | /// A bounced subscriber survives the trip through a daticle, keeping the suppressed state. |
| 1000 | #[test] |
| 1001 | fn test_a_bounced_subscriber_round_trips_09() -> Outcome<()> { |
| 1002 | assert_eq!(SubState::Bounced.as_str(), "bounced"); |
| 1003 | let sub = Subscriber { |
| 1004 | email: fmt!("gone@example.com"), |
| 1005 | state: SubState::Bounced, |
| 1006 | token: fmt!("tok"), |
| 1007 | created: Some(fmt!("2026-07-18T10:00:00Z")), |
| 1008 | }; |
| 1009 | let back = res!(Subscriber::from_dat(&sub.to_dat())); |
| 1010 | assert_eq!(back, sub); |
| 1011 | assert_eq!(back.state, SubState::Bounced); |
| 1012 | Ok(()) |
| 1013 | } |
| 1014 | |
| 1015 | /// A record with no email is not a subscriber: nothing could address it or key it. |
| 1016 | #[test] |
| 1017 | fn test_a_subscriber_without_an_email_is_refused_03() -> Outcome<()> { |
| 1018 | let d = create_dat_ordmap(vec![(dat!("state"), dat!("confirmed"))]); |
| 1019 | assert!(Subscriber::from_dat(&d).is_err()); |
| 1020 | Ok(()) |
| 1021 | } |
| 1022 | |
| 1023 | /// A key is the prefix and the address, so a token's read is index-driven and never a scan. |
| 1024 | #[test] |
| 1025 | fn test_a_key_is_prefixed_04() -> Outcome<()> { |
| 1026 | assert_eq!(key_of("me@example.com"), dat!("publish/subscriber/me@example.com")); |
| 1027 | Ok(()) |
| 1028 | } |
| 1029 | |
| 1030 | /// A token is minted from the small alphabet, at the stated length, and two are not the same. |
| 1031 | #[test] |
| 1032 | fn test_a_token_is_unguessable_shaped_05() -> Outcome<()> { |
| 1033 | let t = mint_token(); |
| 1034 | assert_eq!(t.len(), TOKEN_LEN); |
| 1035 | assert!(t.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())); |
| 1036 | assert_ne!(mint_token(), mint_token(), "two tokens collided"); |
| 1037 | Ok(()) |
| 1038 | } |
| 1039 | |
| 1040 | /// The `token=` field is read raw from the query, and a value that would need decoding is taken as |
| 1041 | /// itself -- which matches no token. |
| 1042 | #[test] |
| 1043 | fn test_a_token_is_read_from_the_query_06() -> Outcome<()> { |
| 1044 | assert_eq!(token_of("token=abc123"), "abc123"); |
| 1045 | assert_eq!(token_of("a=1&token=xyz"), "xyz"); |
| 1046 | assert_eq!(token_of("token="), ""); |
| 1047 | assert_eq!(token_of(""), ""); |
| 1048 | Ok(()) |
| 1049 | } |
| 1050 | |
| 1051 | /// An address is redacted to its first character and domain for a log, never kept whole there. |
| 1052 | #[test] |
| 1053 | fn test_an_address_is_redacted_for_the_log_07() -> Outcome<()> { |
| 1054 | assert_eq!(redact("jason@oxedyne.com"), "j***@oxedyne.com"); |
| 1055 | assert_eq!(redact("not-an-address"), "***"); |
| 1056 | Ok(()) |
| 1057 | } |
| 1058 | |
| 1059 | /// The field no person sees catches a fill and forgives whitespace. |
| 1060 | /// |
| 1061 | /// Whitespace matters: a browser or a proxy that pads the value must not cost a reader their |
| 1062 | /// sign-up, and a bot that writes anything at all must lose theirs. |
| 1063 | #[test] |
| 1064 | fn test_the_trap_catches_a_fill_10() -> Outcome<()> { |
| 1065 | assert!(!trapped("")); |
| 1066 | assert!(!trapped(" ")); |
| 1067 | assert!(!trapped("\t\n")); |
| 1068 | assert!(trapped("http://example.com")); |
| 1069 | assert!(trapped("x")); |
| 1070 | assert!(trapped(" x ")); |
| 1071 | Ok(()) |
| 1072 | } |
| 1073 | |
| 1074 | /// The sign-up limit is policy with a limiting default: a block that names none still limits. |
| 1075 | /// |
| 1076 | /// The default matters more than the number. This endpoint sends mail to an address a stranger |
| 1077 | /// chose, so the failure of omission -- a `publish` block written before these fields existed, |
| 1078 | /// loading with no limit at all -- is the one worth designing against. |
| 1079 | #[test] |
| 1080 | fn test_the_signup_limit_defaults_to_limiting_11() -> Outcome<()> { |
| 1081 | use crate::srv::publish::PublishConfig; |
| 1082 | |
| 1083 | // A block that names nothing about sign-ups still limits them. |
| 1084 | let cfg = res!(PublishConfig::from_datmap(&DaticleMap::new())); |
| 1085 | assert_eq!(cfg.subscribe_rate_secs, 60); |
| 1086 | assert_eq!(cfg.subscribe_rate_hourly, 5); |
| 1087 | |
| 1088 | // A site that names its own numbers is taken at its word. Written as bare counts, which the |
| 1089 | // grammar types as narrowly as it can -- the shape an operator actually writes. |
| 1090 | let mut m = DaticleMap::new(); |
| 1091 | m.insert(dat!("subscribe_rate_secs"), dat!(120u8)); |
| 1092 | m.insert(dat!("subscribe_rate_hourly"), dat!(2u8)); |
| 1093 | let cfg = res!(PublishConfig::from_datmap(&m)); |
| 1094 | assert_eq!(cfg.subscribe_rate_secs, 120); |
| 1095 | assert_eq!(cfg.subscribe_rate_hourly, 2); |
| 1096 | |
| 1097 | // Off is a decision a site may take, and is distinct from naming nothing. |
| 1098 | let mut off = DaticleMap::new(); |
| 1099 | off.insert(dat!("subscribe_rate_secs"), dat!(0u8)); |
| 1100 | off.insert(dat!("subscribe_rate_hourly"), dat!(0u8)); |
| 1101 | let cfg = res!(PublishConfig::from_datmap(&off)); |
| 1102 | assert_eq!(cfg.subscribe_rate_secs, 0); |
| 1103 | assert_eq!(cfg.subscribe_rate_hourly, 0); |
| 1104 | Ok(()) |
| 1105 | } |
| 1106 | |
| 1107 | /// One address hashes to different values for the sign-up counter and the comment counter. |
| 1108 | /// |
| 1109 | /// The separation is the point: a value taken from one counter must not be a lookup key for the |
| 1110 | /// other, or the two features become one another's oracle. |
| 1111 | #[test] |
| 1112 | fn test_the_two_counters_do_not_share_a_hash_12() -> Outcome<()> { |
| 1113 | let salt = b"a-site-secret"; |
| 1114 | let mine = from_hash("203.0.113.7", salt); |
| 1115 | let theirs = super::super::comment::from_hash("203.0.113.7", salt); |
| 1116 | assert_ne!(mine, theirs); |
| 1117 | // Stable for one address, or a limiter counts every request as a new sender. |
| 1118 | assert_eq!(mine, from_hash("203.0.113.7", salt)); |
| 1119 | // And separated by salt, so a value means nothing on another site. |
| 1120 | assert_ne!(mine, from_hash("203.0.113.7", b"another-site")); |
| 1121 | Ok(()) |
| 1122 | } |
| 1123 | |
| 1124 | /// A CSV field carrying a comma or a quote is quoted, so an address never splits a row. |
| 1125 | #[test] |
| 1126 | fn test_a_csv_field_is_quoted_when_it_must_be_08() -> Outcome<()> { |
| 1127 | assert_eq!(csv_field("me@example.com"), "me@example.com"); |
| 1128 | assert_eq!(csv_field("a,b@example.com"), "\"a,b@example.com\""); |
| 1129 | assert_eq!(csv_field("a\"b"), "\"a\"\"b\""); |
| 1130 | Ok(()) |
| 1131 | } |
| 1132 | } |