cratefield_module_email_signup/
lib.rs1#![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
42const 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
57const MIGRATION_UNSUBSCRIBE_TOKEN: SqlMigration = SqlMigration::new(
59 "0003",
60 "unsubscribe_token",
61 include_str!("../migrations/sqlite/0003_unsubscribe_token.sql"),
62);
63
64#[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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
354async 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}