Skip to main content

cratefield_module_email_signup/
lib.rs

1//! `cratefield-module-email-signup`: collect an address, double opt-in via
2//! a signed link, one-click unsubscribe, admin CSV export and hard delete
3//! (issue #10, architecture section 6).
4//!
5//! ```no_run
6//! use cratefield_module_email_signup::EmailSignup;
7//!
8//! let module = EmailSignup::new()
9//!     .double_opt_in(true)
10//!     .confirm_ttl_days(7)
11//!     .retention_days_pending(30);
12//! ```
13//!
14//! **No enumeration** (section 11): `POST /v1/email-signup` answers the
15//! same `202 {"ok":true}` bytes for new, pending, confirmed and
16//! unsubscribed addresses, and at most one confirmation mail is sent per
17//! address per hour. Tokens are HMAC-signed (ADR 0006): confirm links
18//! expire after `confirm_ttl_days`, unsubscribe links never do.
19//!
20//! Register [`default_templates`] with `Harness::builder().templates(..)`
21//! so ventures can override them; the module also falls back to built-in
22//! rendering when the registry has no entry.
23
24#![doc = include_str!("../README.md")]
25#![forbid(unsafe_code)]
26
27mod handlers;
28mod mail;
29mod store;
30
31pub use mail::{ConfirmMailData, WelcomeMailData, default_templates, themed_templates};
32
33use cratefield_core::{
34    AnyError, BoxFuture, Config, ConfigError, DataKind, Disposition, IdGen, Migrations, Module,
35    ModuleConfig, ModuleContext, PersonalDataSet, Port, SqlMigration, UlidIdGen, normalize_email,
36    validation_error,
37};
38use std::sync::{Arc, OnceLock};
39
40use handlers::Settings;
41
42/// The module's migrations: the `subscribers` table in the portable SQL
43/// subset (issue #10, architecture section 7), plus the subscription
44/// generation that confirmation tokens bind to (issue #127).
45const MIGRATION_INIT: SqlMigration = SqlMigration::new(
46    "0001",
47    "init",
48    include_str!("../migrations/sqlite/0001_init.sql"),
49);
50
51const MIGRATION_SUBSCRIPTION_GENERATION: SqlMigration = SqlMigration::new(
52    "0002",
53    "subscription_generation",
54    include_str!("../migrations/sqlite/0002_subscription_generation.sql"),
55);
56
57/// The revocable per-subscription unsubscribe token (issue #137).
58const MIGRATION_UNSUBSCRIBE_TOKEN: SqlMigration = SqlMigration::new(
59    "0003",
60    "unsubscribe_token",
61    include_str!("../migrations/sqlite/0003_unsubscribe_token.sql"),
62);
63
64/// Email signup with double opt-in.
65///
66/// The `waitlist.confirmed` handler needs a [`ModuleContext`], but event
67/// handlers are registered at `Harness::build` and receive none — so each
68/// module instance parks the context from its first `router()` build in
69/// this cell (per instance, so parallel harnesses in tests stay
70/// isolated). It holds wiring state, never request state (ADR 0007).
71#[derive(Clone)]
72pub struct EmailSignup {
73    settings: Settings,
74    ctx_cell: Arc<OnceLock<Arc<ModuleContext>>>,
75}
76
77impl Default for EmailSignup {
78    fn default() -> Self {
79        Self::new()
80    }
81}
82
83impl EmailSignup {
84    /// Double opt-in on, 7-day confirm TTL, 30-day pending retention.
85    pub fn new() -> Self {
86        Self {
87            settings: Settings {
88                double_opt_in: true,
89                confirm_ttl_days: 7,
90                retention_days_pending: 30,
91                confirmed_redirect: None,
92                unsubscribed_redirect: None,
93                expired_redirect: None,
94                welcome_on_confirm: false,
95                subscribe_on_waitlist_confirm: false,
96            },
97            ctx_cell: Arc::new(OnceLock::new()),
98        }
99    }
100
101    /// Double opt-in (default `true`). When off, a signup is confirmed
102    /// immediately and no confirmation mail is sent.
103    #[must_use]
104    pub fn double_opt_in(mut self, enabled: bool) -> Self {
105        self.settings.double_opt_in = enabled;
106        self
107    }
108
109    /// How long a confirmation link stays valid (default 7 days).
110    #[must_use]
111    pub fn confirm_ttl_days(mut self, days: u32) -> Self {
112        self.settings.confirm_ttl_days = days;
113        self
114    }
115
116    /// Redirect target after a successful confirm (default
117    /// `<public_url>/confirmed`).
118    #[must_use]
119    pub fn confirmed_redirect(mut self, url: impl Into<String>) -> Self {
120        self.settings.confirmed_redirect = Some(url.into());
121        self
122    }
123
124    /// Redirect target after unsubscribing (default
125    /// `<public_url>/unsubscribed`).
126    #[must_use]
127    pub fn unsubscribed_redirect(mut self, url: impl Into<String>) -> Self {
128        self.settings.unsubscribed_redirect = Some(url.into());
129        self
130    }
131
132    /// Redirect target for expired/invalid confirm links (default
133    /// `<public_url>/confirm-expired`).
134    #[must_use]
135    pub fn expired_redirect(mut self, url: impl Into<String>) -> Self {
136        self.settings.expired_redirect = Some(url.into());
137        self
138    }
139
140    /// Purge `pending` rows untouched for this many days from the
141    /// scheduled handler (default 30; issue #13 retention rule).
142    #[must_use]
143    pub fn retention_days_pending(mut self, days: u32) -> Self {
144        self.settings.retention_days_pending = days;
145        self
146    }
147
148    /// Send the `email-signup/welcome` mail (deferred) when a signup is
149    /// confirmed. Off by default (issue #10).
150    #[must_use]
151    pub fn welcome_on_confirm(mut self, enabled: bool) -> Self {
152        self.settings.welcome_on_confirm = enabled;
153        self
154    }
155
156    /// Subscribe addresses to the signup list (as `confirmed`) when they
157    /// confirm a waitlist entry — the waitlist link already proved the
158    /// address. Wired through the `waitlist.confirmed` event, so there is
159    /// no dependency between the module crates.
160    #[must_use]
161    pub fn subscribe_on_waitlist_confirm(mut self, enabled: bool) -> Self {
162        self.settings.subscribe_on_waitlist_confirm = enabled;
163        self
164    }
165}
166
167impl Module for EmailSignup {
168    fn name(&self) -> &'static str {
169        "email-signup"
170    }
171
172    fn version(&self) -> &'static str {
173        env!("CARGO_PKG_VERSION")
174    }
175
176    fn requires(&self) -> &'static [Port] {
177        &[Port::Db, Port::Mailer, Port::Signer]
178    }
179
180    fn optional(&self) -> &'static [Port] {
181        &[Port::Captcha, Port::RateLimiter]
182    }
183
184    fn tables(&self) -> &'static [&'static str] {
185        &["subscribers"]
186    }
187
188    /// The one table, and the one column an export must not copy (issue #265).
189    ///
190    /// **Why `id` and not the address.** Every other handle on a subscriber in
191    /// this module is the opaque row id, and deliberately: the admin delete
192    /// route takes `{id}` and never the address, because a URL outlives its
193    /// request in access logs, proxies and browser history (issue #135).
194    /// `GET /v1/privacy/export?subject=…` puts the subject value in exactly
195    /// such a URL, so declaring `email_normalized` here would have made a
196    /// subject access request the one operation that writes the address into
197    /// the logs the rest of the module is built to keep it out of.
198    ///
199    /// **Why `unsubscribe_token` is redacted.** It is a bearer capability
200    /// (issue #137, ADR 0014): whoever holds the value can unsubscribe that
201    /// subscription, which is why the row rotates it with each mail rather
202    /// than relying on a signing key. An export is a `SELECT *` written to a
203    /// file people forward, so copying it in would hand that capability to
204    /// everyone the file reaches. It is named with `[redacted]` for a value
205    /// rather than dropped, because "we hold nothing there" is the one thing
206    /// a subject access request must not say untruthfully — and it is still
207    /// erased, because the row it lives in goes.
208    fn personal_data(&self) -> &'static [PersonalDataSet] {
209        const SETS: &[PersonalDataSet] = &[PersonalDataSet {
210            table: "subscribers",
211            subject: "id",
212            kind: DataKind::Contact,
213            disposition: Disposition::Erase,
214            description: "The email address you gave us and the one we matched it against, \
215                          where you signed up from, the language you asked to be written to \
216                          in, and when you signed up, confirmed and unsubscribed.",
217            redacted: &["unsubscribe_token"],
218            subject_via: None,
219        }];
220        SETS
221    }
222
223    fn emits(&self) -> &'static [&'static str] {
224        &[handlers::EVENT_CONFIRMED, handlers::EVENT_UNSUBSCRIBED]
225    }
226
227    fn public_writes(&self) -> bool {
228        true
229    }
230
231    fn migrations(&self) -> Migrations {
232        const MIGRATIONS: [SqlMigration; 3] = [
233            MIGRATION_INIT,
234            MIGRATION_SUBSCRIPTION_GENERATION,
235            MIGRATION_UNSUBSCRIBE_TOKEN,
236        ];
237        // The array is the apply order; this refuses a gap, a duplicate
238        // or an entry out of order at build time (issue #27).
239        const _: () = cratefield_core::assert_migration_set(&MIGRATIONS);
240        Migrations {
241            sqlite: &MIGRATIONS,
242            postgres: &[],
243        }
244    }
245
246    fn validate_config(&self, cfg: &dyn Config) -> Result<(), ConfigError> {
247        let module = ModuleConfig::new("email-signup", cfg);
248        let mut errors = ConfigError::default();
249
250        for key in ["CONFIRM_TTL_DAYS", "RETENTION_DAYS_PENDING"] {
251            match cfg.get(&module.key(key)).map(|raw| raw.parse::<u32>()) {
252                Some(Ok(value)) if value > 0 => {}
253                Some(_) => errors.push(format!(
254                    "email-signup: {} must be a positive integer",
255                    module.key(key)
256                )),
257                None => {}
258            }
259        }
260        for key in [
261            "CONFIRMED_REDIRECT",
262            "UNSUBSCRIBED_REDIRECT",
263            "EXPIRED_REDIRECT",
264            "API_BASE",
265        ] {
266            if let Some(raw) = cfg.get(&module.key(key))
267                && !(raw.starts_with("https://") || raw.starts_with("http://"))
268            {
269                errors.push(format!(
270                    "email-signup: {} must be an absolute http(s) URL, got {raw:?}",
271                    module.key(key)
272                ));
273            }
274        }
275        for key in [
276            "DOUBLE_OPT_IN",
277            "WELCOME_ON_CONFIRM",
278            "SUBSCRIBE_ON_WAITLIST_CONFIRM",
279        ] {
280            if let Some(raw) = cfg.get(&module.key(key))
281                && !matches!(
282                    raw.to_ascii_lowercase().as_str(),
283                    "1" | "true" | "yes" | "on" | "0" | "false" | "no" | "off"
284                )
285            {
286                errors.push(format!(
287                    "email-signup: {} must be a boolean, got {raw:?}",
288                    module.key(key)
289                ));
290            }
291        }
292        if let Some(raw) = cfg.get(&module.key("FROM"))
293            && !raw.contains('@')
294        {
295            errors.push(format!(
296                "email-signup: {} must be an address, got {raw:?}",
297                module.key("FROM")
298            ));
299        }
300
301        errors.into_result()
302    }
303
304    fn router(&self, ctx: ModuleContext) -> axum::Router {
305        let shared = Arc::new(ctx);
306        // First build wins; on Workers every request rebuilds the router
307        // with equivalent ports, so the parked context stays valid.
308        let _ = self.ctx_cell.set(Arc::clone(&shared));
309        handlers::router(shared, self.settings.clone())
310    }
311
312    fn surface(&self) -> cratefield_core::Surface {
313        handlers::surface(&self.settings)
314    }
315
316    fn events(&self) -> Vec<(cratefield_core::EventName, cratefield_core::EventHandler)> {
317        if !self.settings.subscribe_on_waitlist_confirm {
318            return Vec::new();
319        }
320        let cell = Arc::clone(&self.ctx_cell);
321        let handler: cratefield_core::EventHandler = Arc::new(move |_scope, payload| {
322            let cell = Arc::clone(&cell);
323            Box::pin(async move { subscribe_waitlist_confirmed(&cell, payload).await })
324        });
325        vec![(handlers::WAITLIST_CONFIRMED.to_owned(), handler)]
326    }
327
328    fn scheduled<'a>(
329        &'a self,
330        ctx: &'a ModuleContext,
331        cron: &'a str,
332    ) -> BoxFuture<'a, Result<(), AnyError>> {
333        Box::pin(async move {
334            let Some(db) = ctx.ports.db.clone() else {
335                return Ok(());
336            };
337            let cfg = ModuleConfig::new("email-signup", &*ctx.config);
338            let days = i64::from(cfg.get_u32(
339                "RETENTION_DAYS_PENDING",
340                self.settings.retention_days_pending,
341            ));
342            let cutoff = handlers::iso_ago(days.saturating_mul(86_400));
343            let deleted = store::purge_pending_older_than(&*db, &cutoff)
344                .await
345                .map_err(|err| Box::new(err) as AnyError)?;
346            if deleted > 0 {
347                tracing::info!(deleted, cron, "purged stale pending subscribers");
348            }
349            Ok(())
350        })
351    }
352}
353
354/// `waitlist.confirmed` handler: add the (now proven) address to the
355/// signup list as `confirmed`, unless it is already there.
356async fn subscribe_waitlist_confirmed(
357    cell: &Arc<OnceLock<Arc<ModuleContext>>>,
358    payload: serde_json::Value,
359) -> Result<(), AnyError> {
360    use serde_json::Value;
361
362    let Some(email) = payload.get("email").and_then(Value::as_str) else {
363        tracing::warn!("waitlist.confirmed without an email; ignored");
364        return Ok(());
365    };
366    let product = payload
367        .get("product")
368        .and_then(Value::as_str)
369        .unwrap_or("unknown");
370    let normalized = normalize_email(email);
371    if validation_error(&normalized).is_some() {
372        tracing::warn!(product, "waitlist.confirmed with an invalid email; ignored");
373        return Ok(());
374    }
375    let Some(ctx) = cell.get() else {
376        return Err("email-signup router was never built".into());
377    };
378    let Some(db) = ctx.ports.db.clone() else {
379        return Err("email-signup has no Database port".into());
380    };
381    if store::find_by_normalized_email(&*db, &normalized)
382        .await
383        .map_err(|err| Box::new(err) as AnyError)?
384        .is_some()
385    {
386        return Ok(());
387    }
388    let now = handlers::now_iso();
389    store::insert_row(
390        &*db,
391        &store::SubscriberRow {
392            id: UlidIdGen.ulid(),
393            generation: 1,
394            email: normalized.clone(),
395            email_normalized: normalized,
396            status: store::STATUS_CONFIRMED.to_owned(),
397            source: Some(format!("waitlist:{product}")),
398            locale: None,
399            confirmed_at: Some(now.clone()),
400            unsubscribed_at: None,
401            unsubscribe_token: None,
402            created_at: now.clone(),
403            updated_at: now,
404        },
405    )
406    .await
407    .map_err(|err| Box::new(err) as AnyError)?;
408    tracing::info!(product, "subscribed a confirmed waitlist address");
409    Ok(())
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    #[test]
417    fn defaults_match_the_issue() {
418        let module = EmailSignup::new();
419        assert!(module.settings.double_opt_in);
420        assert_eq!(module.settings.confirm_ttl_days, 7);
421        assert_eq!(module.settings.retention_days_pending, 30);
422        assert!(!module.settings.welcome_on_confirm);
423        assert!(!module.settings.subscribe_on_waitlist_confirm);
424        assert_eq!(module.name(), "email-signup");
425        assert_eq!(module.tables(), ["subscribers"]);
426        assert!(module.public_writes());
427        assert_eq!(module.requires(), [Port::Db, Port::Mailer, Port::Signer]);
428        assert_eq!(module.optional(), [Port::Captcha, Port::RateLimiter]);
429    }
430
431    #[test]
432    fn instances_do_not_share_a_context_cell() {
433        let a = EmailSignup::new();
434        let b = EmailSignup::new();
435        assert!(!Arc::ptr_eq(&a.ctx_cell, &b.ctx_cell));
436    }
437}