Skip to main content

max / makenotwork

3.2 KB · 94 lines History Blame Raw
1 //! Email signup capture (launch waitlist / newsletter): insert a signup with
2 //! its `source`, and read the full list + count for the admin view.
3
4 use sqlx::PgPool;
5 use uuid::Uuid;
6
7 use crate::error::Result;
8
9 /// Insert a new email signup, ignoring duplicates.
10 /// Returns the signup ID (new or existing).
11 ///
12 /// Re-signing up clears a previous unsubscribe: submitting the form again is a
13 /// fresh act of consent, and refusing to honour it would leave someone unable
14 /// to opt back in through the only interface that exists.
15 #[tracing::instrument(skip_all)]
16 pub(crate) async fn insert_email_signup(pool: &PgPool, email: &str, source: &str) -> Result<Uuid> {
17 let id = sqlx::query_scalar!(
18 r#"
19 INSERT INTO email_signups (email, source)
20 VALUES ($1, $2)
21 ON CONFLICT (email) DO UPDATE SET email = EXCLUDED.email, unsubscribed_at = NULL
22 RETURNING id
23 "#,
24 email,
25 source,
26 )
27 .fetch_one(pool)
28 .await?;
29 Ok(id)
30 }
31
32 /// Mark an address unsubscribed. Returns whether a subscribed row was found.
33 ///
34 /// The row is marked, not deleted. A deleted address is one the next import
35 /// would happily re-add, so the record of the opt-out is what honours it.
36 /// Idempotent: unsubscribing twice reports `false` the second time and is not
37 /// an error, which matters because RFC 8058 one-click POSTs get retried.
38 #[tracing::instrument(skip_all)]
39 pub(crate) async fn unsubscribe_email_signup(pool: &PgPool, email: &str) -> Result<bool> {
40 let affected = sqlx::query!(
41 "UPDATE email_signups SET unsubscribed_at = NOW() \
42 WHERE LOWER(email) = LOWER($1) AND unsubscribed_at IS NULL",
43 email,
44 )
45 .execute(pool)
46 .await?
47 .rows_affected();
48 Ok(affected > 0)
49 }
50
51 /// Row returned by the admin email signups query.
52 pub(crate) struct DbEmailSignup {
53 pub email: String,
54 pub source: String,
55 pub created_at: chrono::DateTime<chrono::Utc>,
56 }
57
58 /// Get mailable email signups, newest first (capped at 500).
59 ///
60 /// Unsubscribed rows are excluded here rather than filtered by the caller. This
61 /// is the query a send would draw its recipients from, so the opt-out belongs
62 /// inside it: a caller that has to remember to filter is a caller that will
63 /// eventually forget.
64 #[tracing::instrument(skip_all)]
65 pub(crate) async fn get_all_email_signups(pool: &PgPool) -> Result<Vec<DbEmailSignup>> {
66 let rows = sqlx::query_as!(
67 DbEmailSignup,
68 r#"
69 SELECT email, source,
70 created_at as "created_at: chrono::DateTime<chrono::Utc>"
71 FROM email_signups
72 WHERE unsubscribed_at IS NULL
73 ORDER BY created_at DESC
74 LIMIT 500
75 "#,
76 )
77 .fetch_all(pool)
78 .await?;
79 Ok(rows)
80 }
81
82 /// Count mailable email signups. Excludes unsubscribed, matching
83 /// [`get_all_email_signups`], so the admin count is the size of the list that
84 /// would actually receive a send.
85 #[tracing::instrument(skip_all)]
86 pub(crate) async fn count_email_signups(pool: &PgPool) -> Result<i64> {
87 let count =
88 sqlx::query_scalar!("SELECT COUNT(*) FROM email_signups WHERE unsubscribed_at IS NULL")
89 .fetch_one(pool)
90 .await?
91 .unwrap_or(0);
92 Ok(count)
93 }
94