Oregami
Repositories/oxedyne/fe2o3

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
26use crate::srv::publish::{
27 PublishConfig,
28 send::{
29 self,
30 MailSender,
31 },
32 page,
33};
34
35use oxedyne_fe2o3_core::{
36 prelude::*,
37 rand::Rand,
38};
39use oxedyne_fe2o3_iop_crypto::enc::Encrypter;
40use oxedyne_fe2o3_iop_db::api::Database;
41use oxedyne_fe2o3_iop_hash::api::Hasher;
42use oxedyne_fe2o3_jdat::{
43 prelude::*,
44 id::NumIdDat,
45};
46use oxedyne_fe2o3_net::{
47 http::msg::HttpMessage,
48 smtp::client::is_permanent,
49};
50
51use std::sync::{
52 Arc,
53 RwLock,
54};
55
56
57pub const KEY_PREFIX: &str = "publish/subscriber/";
58
59pub 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.
63pub 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.
68pub 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.
72const 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)]
84pub 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
95impl 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)]
123pub 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
130impl 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
182fn 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.
192pub 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.
203pub 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.
234pub 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.
241pub const TRAP_FIELD: &str = "website";
242
243const 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.
249pub 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.
258fn 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
267pub 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.
288fn 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
313fn 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
347fn 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.
370pub 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.
396pub 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.
416pub 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.
436pub 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.
462fn 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.
474fn 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.
512pub 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)]
551pub 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
561pub 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)]
591pub 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.
600pub 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.
632pub 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.
665pub 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.
697pub 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.
733pub 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`.
752pub 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.
780pub 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=...`.
873pub 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.
903pub 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.
934fn 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)]
948mod 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}