Skip to main content

max / makenotwork

9.7 KB · 324 lines History Blame Raw
1 //! Mailing list CRUD: per-project lists and subscriber management.
2
3 use sqlx::PgPool;
4
5 use super::enums::MailingListType;
6 use super::id_types::{MailingListId, ProjectId, UserId};
7 use super::models::DbMailingList;
8 use crate::error::Result;
9
10 /// Create a mailing list for a project. Idempotent via ON CONFLICT.
11 #[tracing::instrument(skip_all)]
12 pub async fn create_list(
13 pool: &PgPool,
14 project_id: ProjectId,
15 list_type: MailingListType,
16 name: &str,
17 description: Option<&str>,
18 ) -> Result<DbMailingList> {
19 let row = sqlx::query_as::<_, DbMailingList>(
20 r"
21 INSERT INTO mailing_lists (project_id, list_type, name, description)
22 VALUES ($1, $2, $3, $4)
23 ON CONFLICT (project_id, list_type) DO UPDATE SET name = EXCLUDED.name
24 RETURNING *
25 ",
26 )
27 .bind(project_id)
28 .bind(list_type)
29 .bind(name)
30 .bind(description)
31 .fetch_one(pool)
32 .await?;
33
34 // Mirror into the unified `lists` table, which is what sends read from now
35 // (see db::lists). Errors propagate: a list that exists only in the legacy
36 // table is one no send can reach.
37 super::lists::mirror_legacy_list(pool, project_id.into(), list_type.into(), name).await?;
38
39 Ok(row)
40 }
41
42 /// Look up a list by project and type.
43 #[tracing::instrument(skip_all)]
44 pub async fn get_list_by_project_and_type(
45 pool: &PgPool,
46 project_id: ProjectId,
47 list_type: MailingListType,
48 ) -> Result<Option<DbMailingList>> {
49 let row = sqlx::query_as::<_, DbMailingList>(
50 "SELECT * FROM mailing_lists WHERE project_id = $1 AND list_type = $2",
51 )
52 .bind(project_id)
53 .bind(list_type)
54 .fetch_optional(pool)
55 .await?;
56
57 Ok(row)
58 }
59
60 /// Subscribe a user to a list. Idempotent via ON CONFLICT DO NOTHING.
61 #[tracing::instrument(skip_all)]
62 pub async fn subscribe(pool: &PgPool, list_id: MailingListId, user_id: UserId) -> Result<()> {
63 sqlx::query(
64 r"
65 INSERT INTO mailing_list_subscribers (list_id, user_id)
66 VALUES ($1, $2)
67 ON CONFLICT (list_id, user_id) DO NOTHING
68 ",
69 )
70 .bind(list_id)
71 .bind(user_id)
72 .execute(pool)
73 .await?;
74
75 super::lists::mirror_legacy_subscribe(
76 pool,
77 list_id.into(),
78 &super::lists::Subscriber::User(user_id),
79 super::SubscriptionState::Confirmed,
80 super::SubscriptionSource::ProjectPage,
81 Some("Subscribed through the product (project page, follow, or purchase)."),
82 )
83 .await?;
84
85 Ok(())
86 }
87
88 /// Unsubscribe a user from a specific list. Returns true if a row was deleted.
89 #[tracing::instrument(skip_all)]
90 pub async fn unsubscribe(pool: &PgPool, list_id: MailingListId, user_id: UserId) -> Result<bool> {
91 let result =
92 sqlx::query("DELETE FROM mailing_list_subscribers WHERE list_id = $1 AND user_id = $2")
93 .bind(list_id)
94 .bind(user_id)
95 .execute(pool)
96 .await?;
97
98 super::lists::mirror_legacy_unsubscribe_user(pool, list_id.into(), user_id).await?;
99
100 Ok(result.rows_affected() > 0)
101 }
102
103 /// Unsubscribe a user from ALL mailing lists on a project (used on unfollow).
104 #[tracing::instrument(skip_all)]
105 pub async fn unsubscribe_from_project(
106 pool: &PgPool,
107 project_id: ProjectId,
108 user_id: UserId,
109 ) -> Result<u64> {
110 let result = sqlx::query(
111 r"
112 DELETE FROM mailing_list_subscribers
113 WHERE user_id = $1
114 AND list_id IN (SELECT id FROM mailing_lists WHERE project_id = $2)
115 ",
116 )
117 .bind(user_id)
118 .bind(project_id)
119 .execute(pool)
120 .await?;
121
122 super::lists::mirror_legacy_unsubscribe_project(pool, project_id.into(), user_id).await?;
123
124 Ok(result.rows_affected())
125 }
126
127 /// Unsubscribe an email-only subscriber (no MNW account) from a list. Removes
128 /// the `(list_id, email)` row. Idempotent. Backs the email-keyed unsubscribe
129 /// link carried in emails to imported subscribers.
130 #[tracing::instrument(skip_all)]
131 pub async fn unsubscribe_by_email(
132 pool: &PgPool,
133 list_id: MailingListId,
134 email: &str,
135 ) -> Result<bool> {
136 let result = sqlx::query(
137 "DELETE FROM mailing_list_subscribers WHERE list_id = $1 AND LOWER(email) = LOWER($2) AND user_id IS NULL",
138 )
139 .bind(list_id)
140 .bind(email)
141 .execute(pool)
142 .await?;
143
144 super::lists::mirror_legacy_unsubscribe_email(pool, list_id.into(), email).await?;
145
146 Ok(result.rows_affected() > 0)
147 }
148
149 /// Auto-create the default content + devlog lists for a new project.
150 #[tracing::instrument(skip_all)]
151 pub async fn create_default_lists(
152 pool: &PgPool,
153 project_id: ProjectId,
154 project_title: &str,
155 ) -> Result<()> {
156 create_list(
157 pool,
158 project_id,
159 MailingListType::Content,
160 &format!("{project_title}: Content"),
161 Some("New releases and content updates"),
162 )
163 .await?;
164
165 create_list(
166 pool,
167 project_id,
168 MailingListType::Devlog,
169 &format!("{project_title}: Devlog"),
170 Some("Development updates and behind-the-scenes"),
171 )
172 .await?;
173
174 Ok(())
175 }
176
177 /// Batch-subscribe many emails to a list in a single statement.
178 ///
179 /// Used by the import pipeline, where subscribing one email per query is an
180 /// N+1 storm (a 100k-subscriber import would issue 100k sequential INSERTs on
181 /// the shared pool). Emails are lowercased and deduplicated within the batch;
182 /// `ON CONFLICT DO NOTHING` skips ones already subscribed. Returns the number of
183 /// rows actually inserted (new subscribers).
184 #[tracing::instrument(skip_all, fields(count = emails.len()))]
185 pub async fn subscribe_many_by_email(
186 pool: &PgPool,
187 list_id: MailingListId,
188 emails: &[String],
189 ) -> Result<u64> {
190 if emails.is_empty() {
191 return Ok(0);
192 }
193 let lowered: Vec<String> = emails.iter().map(|e| e.to_lowercase()).collect();
194 let result = sqlx::query(
195 r"
196 INSERT INTO mailing_list_subscribers (list_id, email)
197 SELECT $1, sub_email FROM UNNEST($2::text[]) AS sub_email
198 ON CONFLICT DO NOTHING
199 ",
200 )
201 .bind(list_id)
202 .bind(&lowered)
203 .execute(pool)
204 .await?;
205
206 // Mirror as `imported`, not `confirmed`. This is a creator's CSV upload:
207 // the addresses did not opt in here and we hold no evidence that they
208 // opted in anywhere, so the state and the event say import rather than
209 // manufacturing consent the row cannot support.
210 for email in &lowered {
211 super::lists::mirror_legacy_subscribe(
212 pool,
213 list_id.into(),
214 &super::lists::Subscriber::Email(email.clone()),
215 super::SubscriptionState::Imported,
216 super::SubscriptionSource::Import,
217 Some("Bulk import from a creator-supplied subscriber list."),
218 )
219 .await?;
220 }
221
222 Ok(result.rows_affected())
223 }
224
225 /// Convenience: find the content list for a project and subscribe a user.
226 /// No-op if the content list doesn't exist yet.
227 #[tracing::instrument(skip_all)]
228 pub async fn subscribe_to_content_list(
229 pool: &PgPool,
230 project_id: ProjectId,
231 user_id: UserId,
232 ) -> Result<()> {
233 if let Some(list) =
234 get_list_by_project_and_type(pool, project_id, MailingListType::Content).await?
235 {
236 subscribe(pool, list.id, user_id).await?;
237 }
238
239 Ok(())
240 }
241
242 #[cfg(test)]
243 mod tests {
244 use super::*;
245
246 #[test]
247 fn mailing_list_type_content_roundtrip() {
248 assert_eq!(MailingListType::Content.to_string(), "content");
249 assert_eq!(
250 "content".parse::<MailingListType>().unwrap(),
251 MailingListType::Content
252 );
253 }
254
255 #[test]
256 fn mailing_list_type_devlog_roundtrip() {
257 assert_eq!(MailingListType::Devlog.to_string(), "devlog");
258 assert_eq!(
259 "devlog".parse::<MailingListType>().unwrap(),
260 MailingListType::Devlog
261 );
262 }
263
264 #[test]
265 fn mailing_list_type_patches_roundtrip() {
266 assert_eq!(MailingListType::Patches.to_string(), "patches");
267 assert_eq!(
268 "patches".parse::<MailingListType>().unwrap(),
269 MailingListType::Patches
270 );
271 }
272
273 #[test]
274 fn mailing_list_type_invalid_parse_fails() {
275 assert!("newsletter".parse::<MailingListType>().is_err());
276 assert!("".parse::<MailingListType>().is_err());
277 assert!("CONTENT".parse::<MailingListType>().is_err());
278 }
279
280 #[test]
281 fn mailing_list_type_serde_json_roundtrip() {
282 let val = MailingListType::Content;
283 let json = serde_json::to_string(&val).unwrap();
284 assert_eq!(json, "\"content\"");
285 let parsed: MailingListType = serde_json::from_str(&json).unwrap();
286 assert_eq!(parsed, val);
287 }
288
289 #[test]
290 fn default_list_name_format_content() {
291 let name = format!("{}: Content", "My Project");
292 assert_eq!(name, "My Project: Content");
293 }
294
295 #[test]
296 fn default_list_name_format_devlog() {
297 let name = format!("{}: Devlog", "My Project");
298 assert_eq!(name, "My Project: Devlog");
299 }
300
301 #[test]
302 fn default_list_names_with_special_chars() {
303 let title = "Héllo & World <3>";
304 assert_eq!(format!("{title}: Content"), "Héllo & World <3>: Content");
305 assert_eq!(format!("{title}: Devlog"), "Héllo & World <3>: Devlog");
306 }
307
308 #[test]
309 fn subscribe_by_email_lowercases() {
310 // The function lowercases via email.to_lowercase() before binding.
311 // Verify the stdlib behaviour our code relies on.
312 assert_eq!("FOO@BAR.COM".to_lowercase(), "foo@bar.com");
313 assert_eq!("MiXeD@CaSe.Org".to_lowercase(), "mixed@case.org");
314 }
315
316 #[test]
317 fn id_types_are_distinct() {
318 let ml_id = MailingListId::new();
319 let proj_id = ProjectId::new();
320 // They wrap different UUIDs and are different types, this is a compile-time check.
321 assert_ne!(ml_id.as_uuid(), proj_id.as_uuid());
322 }
323 }
324